外观
会话标签栏横向滚动设计方案
description: 标签数量超出可视宽度时的横向滚动结构、操作方式与自动定位交互设计
created: 2026-10-07 15:43:18
updated: 2026-10-07 16:45:55
author: sjzhao
1. 设计目标与硬约束
scope: 会话标签数量超出标签栏可视宽度时的横向滚动交互,包括结构分层、滚动操作、箭头溢出提示与激活标签自动定位。 边界:不改动右键菜单内容、inline 重命名、中键关闭标签等既有交互;不改动 Session 数据模型与后端;不重新引入标签拖拽排序。 关联代码:
frontend/src/components/wb/WbTabs.vue、frontend/src/style.css、frontend/src/stores/session.ts(仅读取状态)。
| 目标 | 量化口径 |
|---|---|
| 滚动控件常驻 | 溢出时左箭头固定在标签栏最左侧、右箭头固定在最右侧,任意滚动位置下完整可见、可点击 |
| 纯鼠标可达 | 无触控板时,支持左键按住拖拽横移;普通鼠标滚轮一个刻度即产生 ≥40px 横向位移;箭头单击移动 75% 视窗宽度 |
| 激活标签可见 | 切换 / 新建 / 复制会话后,目标标签 100% 完整进入可视区;已可见时不触发任何滚动 |
| 状态同步及时 | 滚动、增删标签、窗口缩放后,箭头状态在 1 帧内更新(rAF 节流,无轮询) |
| 完整名可达 | 标签文本被省略号截断时,鼠标悬停即可看到完整名称;短名称不弹任何提示 |
| 压缩保焦点 | 溢出时只压缩非激活标签(190px→90px),激活标签恒为 190px 不压缩;非激活标签压到 90px 仍溢出才滚动 |
| 影响面收敛 | 所有事件与监听仅作用于标签栏;不改变活动会话、不触碰终端输入 |
| 资源可清理 | 监听、Observer、定时器全部在组件卸载时清理,不允许残留(ContextMenu 泄漏的前车之鉴) |
硬约束:
- 视觉上不出现滚动条(沿用
::-webkit-scrollbar { height:0 }); - 保留触控板横向滑动与 Shift+滚轮的原生滚动行为;
- 滚动位置属于临时 UI 状态,不写入
ui_state、不跨重启恢复。
2. 必须遵守的既有项目约束
- 标签拖拽排序已在标签栏右键菜单设计中明确移除,本设计不重新引入任何拖拽排序;
- 会话 / 标签是本地运行时 UI 状态,不进入配置迁移与导入导出;
- inline 重命名输入框在窗口缩放、标签栏滚动时不得错位(输入框渲染在 tab 内部,随轨道一起滚动即天然满足);
- 全局事件监听必须在组件卸载时兜底清理,异步回调不得操作已销毁 DOM;
- 颜色一律绑定 CSS 变量(
--panel、--hover、--border等),适配暗色主题。
3. 现状盘点与差距
| 位置 | 现状 | 差距 |
|---|---|---|
.tabs 容器 | flex 布局 + overflow-x:auto,滚动条隐藏 | 标签与 + 按钮同居一个滚动容器,无结构分层 |
.tab-add 与 .quick-menu | 位于滚动容器最左端,点击弹出快速连接菜单 | 随本次改版整体移除;新建连接改由空白区右键与应用首页承接 |
| 普通鼠标滚轮 | 仅触控板双指 / Shift+滚轮可横滚 | 纵向滚轮在标签栏上无任何反应,缺 wheel 映射 |
| 溢出提示 | 无 | 缺两侧箭头按钮及边界禁用态,用户无法感知还有隐藏标签 |
| 标签宽度 | 内容驱动 + max-width:190px,溢出立即滚动 | 缺溢出后的压缩阶段;且需保证激活标签不被压缩 |
| 拖拽滚动 | 无 | 缺左键按住拖拽横移能力 |
| 激活定位 | 无 | 新建、切换、复制会话后不会自动把目标标签滚入可视区 |
结论:本设计补齐结构分层、左右分居箭头、拖拽滚动、滚轮映射、宽度压缩(激活标签不压缩)、激活自动滚入六块能力,同时移除 + 快速连接按钮与其 quick-menu,不保留空态引导与渐变遮罩。
4. 目标与非目标
目标
- 三层结构:标签栏(左、右箭头分居视窗两侧)/ 滚动视窗 / 标签轨道;
- 五种滚动操作:触控板原生、Shift+滚轮、普通纵向滚轮映射、左键按住拖拽、箭头单击与长按;
- 溢出感知:箭头按边界自动启用 / 禁用(变灰),用户从箭头状态即可判断两侧是否还有隐藏标签;
- 激活标签按"最小位移"原则自动滚入可视区;
- 正确响应窗口缩放与标签增删;
- 被省略号截断的标签名在悬停时显示完整名称;
- 溢出时只压缩非激活标签(激活标签恒为 190px),非激活标签压到 90px 仍溢出才横向滚动。
非目标
- 不做带惯性(甩动减速)的拖拽——直接跟手、松手即停,行为可预期且实现简单;
- 不做滚动位置持久化与跨重启恢复(重启后激活标签自动滚入即可);
- 不引入虚拟滚动,预期标签规模 <30 个,全量 DOM 开销可忽略;
- 不改变标签宽度策略(保持
max-width:190px与文本省略号); - 不改动右键菜单、重命名、复制会话等既有功能逻辑;
- 不保留标签栏上的
+按钮与 quick-menu 快速连接面板。
5. 总体架构
text
┌─ .tabs-bar (display:flex; height:40px; position:relative) ──────────┐
│ │ ┌─ .tabs-viewport (flex:1; position:relative) ─────────┐ │ │
│ [ ‹ ] │ │ ┌─ .tabs-scroll (width:100%; overflow-x:auto) ────┐ │ │ [ › ] │
│ 左箭头 │ │ │ ┌─ .tabs-track (width:100%) ─────────────────┐ │ │ │ 右箭头 │
│ 溢出才 │ │ │ │ tab │ tab │ tab │ tab │ tab │ ... │ │ │ │ 溢出才 │
│ 展开 │ │ │ └─────────────────────────────────────────────┘ │ │ │ 展开 │
│ │ │ └───────────────────────────────────────────────────┘ │ │ │
│ │ └────────────────────────────────────────────────────────┘ │ │
└────────┴────────────────────────────────────────────────────────────┴────────┘溢出方向仅由两侧分居的箭头表达(启用 / 边界禁用变灰),不使用渐变遮罩(详见 §6.9)。
数据流:
- 三个派生状态
canScroll / atLeft / atRight,由update()直接从滚动视窗的 DOM 几何值推导; - 触发源:视窗
scroll事件(rAF 节流)、ResizeObserver(观察视窗与轨道)、sessions.length变化、左键拖拽(window 上 mousemove/mouseup); - 程序化滚动统一走视窗的
scrollBy()/scrollTo();拖拽场景直接写scrollLeft(要跟手,不能走动画); activeSessionId变化时由独立 watcher 执行ensureVisible();- 标签栏不再承载任何新建连接面板,新建入口只有空白区右键菜单与应用首页。
6. 详细设计
6.1 派生状态
canScroll = scrollWidth - clientWidth > 1(1px 亚像素容差);atLeft = scrollLeft <= 1;atRight = scrollLeft + clientWidth >= scrollWidth - 1。
三个状态仅在 update() 内赋值,直接驱动箭头的 Vue 绑定:canScroll 控制 show,atLeft / atRight 控制 aria-disabled。
交互状态一览(用户在任何场景下看到的控件组合):
| 场景 | 左箭头 | 右箭头 | 用户感知 |
|---|---|---|---|
| 标签总宽 ≤ 视窗 | 整组隐藏 | 整组隐藏 | 无溢出 |
| 滚到最左端 | 禁用变灰 | 启用 | 右侧还有标签 |
| 中间位置 | 启用 | 启用 | 两侧都有标签 |
| 滚到最右端 | 启用 | 禁用变灰 | 左侧还有标签 |
6.2 箭头按钮
- 两个箭头按钮分居滚动视窗两侧:
.tab-arrow-left(‹)在标签栏最左端,.tab-arrow-right(›)在最右端,宽度均为 32px,带与视窗相邻一侧的分隔线; - 仅
canScroll时两个箭头同时显示(透明度 + 位移动画,不用v-if,避免布局抖动);未溢出时整条视窗占满标签栏; at-left/at-right时对应一侧按钮置为禁用态:opacity:.35; pointer-events:none,并同步aria-disabled="true",另一侧保持可用;- 单击:
scrollBy({ left: ±clientWidth * 0.75, behavior:'smooth' }),不改变activeSessionId; - 长按:
pointerdown后 400ms 启动持续滚动,每 16ms 滚动 12px;pointerup/pointerleave/pointercancel停止; title分别为"向左滚动""向右滚动"。
动画手感与打断(适用于箭头单击与 §6.4 的自动滚入):
- 统一使用浏览器原生 smooth 滚动(WebKit 下时长约 200~400ms 随距离自适应,自带 ease-in-out 曲线),不引入 JS 动画库,避免与原生惯性滚动手感不一致;
- 动画进行中用户发起任何新滚动(滚轮、触控板、再次单击箭头、切换标签),新的
scrollBy/scrollTo会立即接管:浏览器取消旧动画、以当前实际位置为新起点,不会出现两段动画叠加或回弹; - 动画期间
scroll事件持续触发,rAF 节流的update()同步刷新箭头启用 / 禁用态,不出现"动画途中按钮状态停在旧位置"; - 不提供"滚动减速/阻尼"自定义:触控板惯性与边缘橡皮筋交由系统处理,保证 macOS / Windows 各自符合平台直觉。
6.3 普通滚轮映射与左键拖拽
普通滚轮映射在视窗上以 addEventListener('wheel', handler, { passive: false }) 注册(必须非 passive 才能 preventDefault),卸载时移除。判定顺序:
e.ctrlKey || e.metaKey→ 直接放行(页面缩放等系统手势);Math.abs(deltaX) > Math.abs(deltaY)→ 放行(触控板横向或斜向滚动交原生处理);- 纯纵向事件 →
preventDefault(),按方向映射:deltaY > 0向右,deltaY < 0向左。
步长换算:
text
deltaMode === 0(像素):step = max(abs(deltaY), 40)
deltaMode === 1(行) :step = abs(deltaY) * 32
deltaMode === 2(页) :step = abs(deltaY) * clientWidth滚动用 behavior:'auto'——滚轮要求即时跟手,不使用平滑动画。已到边界仍继续同向滚动时也执行 preventDefault,避免 WebView 橡皮筋效果。
左键拖拽(视窗上 mousedown,window 上 mousemove / mouseup,三个 handler 均为顶层具名函数保证成对解绑):
mousedown:仅响应主键(button===0),排除.close与.rename-input;立即preventDefault()阻止文本选区;记录起点startX与startScrollLeft;mousemove:位移超过 5px 才判定为拖拽(阈值内松手仍是普通点击切换);判定后给视窗加.dragging类(cursor:grabbing+user-select:none);目标位置scrollLeft = startScrollLeft - dx,rAF 合帧每帧最多写一次;mouseup:移除 window 监听、取消 rAF;若发生过拖拽,在document捕获阶段注册一次性 click 拦截器(stopPropagation+preventDefault),吞掉浏览器补发的 click——拖完不触发标签切换或关闭。
拖拽直接写 scrollLeft、无动画,保证内容与鼠标 1:1 跟手;松手即停,不做甩动惯性。
6.4 激活标签自动滚入
- 以函数 ref 把每个 tab 元素收集进
Map<string, HTMLElement>; watch(activeSessionId, …, { flush:'post' })在 DOM 更新后的下一帧(requestAnimationFrame)执行ensureVisible()——激活会即时触发宽度重排(90↔190,见 §6.8),等布局稳定再测量才能拿到最终几何;- 设视窗左缘留白
PAD = 8px,测量elLeft = tab.offsetLeft(轨道为视窗直接子元素,offsetLeft 即相对视窗内容左缘):
text
elLeft >= scrollLeft + PAD 且 elRight <= scrollLeft + clientWidth - PAD
→ 已完整可见,不滚动(不打扰原则)
elLeft < scrollLeft + PAD
→ scrollTo({ left: elLeft - PAD }) // 左侧被遮,左对齐
elRight > scrollLeft + clientWidth - PAD
→ scrollTo({ left: elRight - clientWidth + PAD }) // 右侧被遮,右对齐- 动画用
behavior:'smooth';命中prefers-reduced-motion: reduce时降级为'auto'; openSession、duplicateSession内部都会设置activeSessionId,新建 / 复制场景由该 watcher 自然覆盖,不另写滚动调用;- 与重命名功能中既有的
activeSessionIdwatcher 相互独立、无冲突。
6.5 更新时机
| 触发源 | 处理 |
|---|---|
视窗 scroll 事件 | rAF 合并调度 update(),同一帧多次滚动只重算一次 |
ResizeObserver | 同时观察视窗与轨道;任何尺寸变化(含窗口缩放、侧栏 / Inspector 折叠)后 update() |
sessions.length | nextTick 后 update():关闭标签后即使浏览器自动夹回 scrollLeft,箭头状态仍需主动重算 |
| 组件卸载 | disconnect() Observer、removeEventListener、取消 rAF、清除长按定时器 |
6.6 右键菜单适配
- 空白区右键处理
onBarContext绑在.tabs-bar上,排除选择器为.tab、.tab-arrow(点击标签或箭头不弹空白菜单); .tabs-track设min-width:100%:标签很少时轨道仍撑满视窗,空白区域可以正常右键;- 原
+按钮(.tab-add)与.quick-menu面板整体删除,相关的quickOpen状态、onQuick处理与hosts引用一并清除,不留死代码; - 新建连接的入口收敛为两处:空白区右键"新建连接…"、应用首页的新建按钮。
6.7 标签完整名提示(tooltip)
- 完整显示名统一为
s.alias || s.host.id; - 不直接给
.name静态绑定title:短名称、完整可见的名称悬停也弹 tooltip 属于打扰; - 改为在
.name上监听mouseenter,进入瞬间做一次几何判断再决定:
text
nameEl.scrollWidth > nameEl.clientWidth
→ nameEl.title = 完整显示名 // 文本确实被省略号截断
→ nameEl.title = '' // 完整可见,不提示- 别名或主机名在悬停前刚被修改的场景由每次
mouseenter重新判断覆盖,无需额外 watcher; - tooltip 使用浏览器原生 title,不自定义浮层:零样式成本且各平台行为一致。
6.8 标签宽度压缩(激活标签不压缩)
压缩完全由 CSS flex 几何驱动,无 JS 计算、无状态:
text
.tab { flex:0 1 auto; width:190px; min-width:90px; max-width:190px }
.tab.active { flex-shrink:0 } /* 激活标签恒为 190px,不参与收缩 */设标签总数 N、视窗宽 W:
- N × 190 ≤ W:所有标签保持 190px,不压缩;
- N × 190 > W:flex 收缩算法只在 N−1 个非激活标签间分配负空间(shrink 均为 1、basis 均为 190,等比),每个非激活标签实际宽 = (W − 190)/(N−1),文本超长部分由省略号处理;激活标签保持 190px;
- (N−1) × 90 + 190 > W:非激活标签全部压到 90px 下限后停止,即使仍有负空间也不压缩激活标签;flex items 溢出轨道,转入横向滚动;
- 压缩阶段
scrollWidth === clientWidth,箭头隐藏;下限被突破后箭头自动出现,与 §6.1 派生状态天然闭环; - 切换激活标签时宽度即时重新分配,不做过渡动画:
.tab的 transition 只含color / background-color / box-shadow,刻意排除 width。- 曾给 width 加
.15s过渡:宽度插值动画与ensureVisible的 smooth 滚动并行,动画期间元素几何逐帧漂移(旧激活标签缩、新激活标签涨),程序化滚动的目标位置被布局变化干扰、停在半路,表现为"点击右缘半露标签激活后,标签仍有一部分被遮住"。移除 width 过渡后,post + 一帧 rAF 时读到的必为最终几何,滚动一次到位(与 Chrome 标签、iTerm2 标签的宽度即时变化一致)。
- 曾给 width 加
阈值(标签数 N;实现上随窗口实时变化,无需硬编码):
| 布局 | 视窗可用宽 W | 开始压缩 N₁ | 转滚动 N₂ |
|---|---|---|---|
| 默认窗口 1200px(侧栏 264 + 检查器 310) | 626px | 4(非激活标签压至约 145px) | 6(190+5×90=640>626) |
| 最小窗口 940px(检查器已隐藏,侧栏 264) | 676px | 4(非激活标签压至约 162px) | 7(190+6×90=730>676) |
即在项目支持的全部窗口尺寸下,第 4 个标签起非激活标签开始压缩,第 6~7 个标签起转为滚动;激活标签在任何阶段都保持 190px。
最小宽 90px 的构成:左右 padding 22px + 圆点 7px + 两个 gap 16px + 关闭钮 15px = 60px 固定开销,另留 30px 文本区(11.5px 字号下约 4~5 个字符)。
6.9 不使用渐变遮罩
溢出方向的表达只依赖左右分居的箭头:溢出时箭头展开,到达边界的一侧 aria-disabled 变灰(opacity .35)。不绘制边缘渐变遮罩。
决策过程与原因:
- 初版在视窗左右缘各放一条 24px 宽 panel→transparent 的渐变遮罩,意图提示"该方向还有隐藏标签";
- 问题一:遮罩挂在滚动容器上时包含块随内容移动,会滚进视窗内部压断激活标签的文字与底部绿条(曾改为不滚动的包装层修复定位);
- 问题二:即使定位正确,24px 遮罩仍会渐变压暗激活标签绿条的端部,在最左 / 最右位置绿条首尾出现明暗渐变,视觉不干净;
- 箭头的展开 / 变灰已完整承载溢出方向与边界信息,遮罩属于重复提示;移除后少一层伪元素与状态类(模板不再需要
is-scrollable / at-left / at-right类,canScroll / atLeft / atRight状态仅服务箭头),绿条在任何滚动位置都完整连续。
6.10 标签内容布局(关闭按钮靠右)
标签内部按三段式布局,统一由 .tab 的 flex 约束驱动:
text
[ 圆点 ] [ 名称(flex:1) ] [ × ]
固定7px 占满中间空间,超长省略号 贴标签右缘.dot固定 7px、flex-shrink:0靠左;.name为flex:1 1 auto; min-width:0,占满中间空间并在超宽时省略号截断;.close为flex-shrink:0,恒贴标签右缘;- 名称很短时中间留白,关闭按钮仍在右侧:各标签的 × 纵向对齐成一列,位置稳定、用户不需要逐标签寻找按钮;
- inline 重命名时
.rename-input同样是flex:1 1 auto,占据名称原来的中间位置,关闭按钮不移动,进入 / 退出编辑无布局跳动。
6.11 魔数汇总
| 取值 | 含义 | 理由 |
|---|---|---|
| 0.75 × clientWidth | 箭头单击步长 | 翻页同时保留 25% 上下文,避免迷失位置 |
| 32px | 单个箭头按钮宽度 | 与原 + 按钮 38px 接近,保证易点且不挤占标签空间 |
| 90px | 标签最小压缩宽 | 固定开销 60px + 30px 文本区(4~5 字符) |
| N₁=4 / N₂=6~7 | 开始压缩 / 转滚动标签数 | 激活标签恒占 190px,由 (N−1) 个非激活标签的压缩空间推导 |
| 5px | 拖拽判定位移阈值 | 阈值内为点击切换,超过才进入拖拽,防止误触 |
| 400ms | 长按判定延迟 | 各操作系统通用的点按 / 长按分界 |
| 12px / 16ms | 长按滚动速度 | 约 750px/s,接近主流控件长按速度 |
| 8px | 可见判定留白 | 证明边缘标签完整可见,而非贴着边缘 |
| 1px | 溢出 / 边界容差 | 吸收高分屏亚像素误差 |
| 40px | 滚轮单步下限 | 保证鼠标滚轮一格刻度可被感知 |
7. 安全与降级
- 敏感数据:本设计只读取 DOM 几何位置,不涉及密码、密钥、PTY 字节流等任何敏感数据;
- 基础降级:即使所有 JS 逻辑失效,
.tabs-scroll的overflow-x:auto仍保留,触控板与 Shift+滚轮可横滚; - Observer 缺失:在不支持
ResizeObserver的旧 WebView 上,退化为监听window的resize与sessions.length; - 动画降级:浏览器不支持
scrollTo的 smooth 选项时自动按瞬时滚动执行;系统开启"减少动态效果"时主动选择瞬时滚动; - 定时器安全:长按持续滚动的回调先判视窗引用是否存在,组件卸载后不产生对已销毁 DOM 的调用。
8. 测试与验证
- 单测:把"几何值 → canScroll/atLeft/atRight"的推导抽为纯函数,覆盖未溢出、左边界、右边界、亚像素误差四种输入;
- 浏览器 e2e:
- 标签不溢出时无箭头;开到溢出后箭头组出现;
- 单击右箭头位移约为 0.75 视窗宽;到达右边界后右箭头禁用变灰;
- 纵向滚轮映射为横向滚动;Ctrl+滚轮与触控板斜向滚动放行;
- 点击视窗外的标签,标签平滑滚入并完整可见;已可见的标签切换不产生滚动;
- 关闭标签至不再溢出,箭头组隐藏、视窗滚动位置复位;
- 拖动窗口缩放、折叠侧栏 / Inspector,状态在一帧内更新;
- 任意滚动位置下左右箭头分居两侧且可点击;未溢出时两个箭头同时隐藏,视窗占满标签栏;
- 名称被截断的标签悬停出现完整名 tooltip,短名称悬停无 tooltip;
- 第 4 个标签起非激活标签压缩(激活标签保持 190px);第 6(最小窗口下第 7)个标签起非激活标签压到 90px 并出现箭头;
- 在标签上按住左键拖动可横向滚动(光标 grabbing、无文本选中);位移 ≤5px 松手仍按点击处理;拖完松手不触发标签切换或关闭; 10.1 右缘只露出前半部分的标签,点击激活后自动滚到完整可见(× 与右缘全部进入视窗),不再残留被遮区域;左缘半露标签同理; 10.2 激活标签在任意滚动位置底部绿条连续、完整、无折断、无端头压暗;
- 所有标签的关闭按钮贴右缘、纵向对齐成一列;短名称与长名称(省略号)标签 × 位置一致;
- 原
+按钮、quick-menu 与空态引导均无残留;标签右键、中键关闭、inline 重命名三项既有功能不回归;
- 手工验证:macOS(触控板 + 普通鼠标)与 Windows(普通鼠标 + Shift+滚轮)各完整走查一遍。
9. 未决问题
Ctrl+Tab/Ctrl+Shift+Tab键盘循环切换标签:终端区按键由 xterm 捕获,需通过其attachCustomKeyEventHandler专门接线并处理与终端内快捷键的冲突,滚动部分可直接复用 §6.4,本期暂不实现。