Skip to content

会话标签栏横向滚动设计方案 ​

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. 目标与非目标 ​

目标 ​

  1. 三层结构:标签栏(左、右箭头分居视窗两侧)/ 滚动视窗 / 标签轨道;
  2. 五种滚动操作:触控板原生、Shift+滚轮、普通纵向滚轮映射、左键按住拖拽、箭头单击与长按;
  3. 溢出感知:箭头按边界自动启用 / 禁用(变灰),用户从箭头状态即可判断两侧是否还有隐藏标签;
  4. 激活标签按"最小位移"原则自动滚入可视区;
  5. 正确响应窗口缩放与标签增删;
  6. 被省略号截断的标签名在悬停时显示完整名称;
  7. 溢出时只压缩非激活标签(激活标签恒为 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),卸载时移除。判定顺序:

  1. e.ctrlKey || e.metaKey → 直接放行(页面缩放等系统手势);
  2. Math.abs(deltaX) > Math.abs(deltaY) → 放行(触控板横向或斜向滚动交原生处理);
  3. 纯纵向事件 → 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 均为顶层具名函数保证成对解绑):

  1. mousedown:仅响应主键(button===0),排除 .close 与 .rename-input;立即 preventDefault() 阻止文本选区;记录起点 startX 与 startScrollLeft;
  2. mousemove:位移超过 5px 才判定为拖拽(阈值内松手仍是普通点击切换);判定后给视窗加 .dragging 类(cursor:grabbing + user-select:none);目标位置 scrollLeft = startScrollLeft - dx,rAF 合帧每帧最多写一次;
  3. 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 自然覆盖,不另写滚动调用;
  • 与重命名功能中既有的 activeSessionId watcher 相互独立、无冲突。

6.5 更新时机 ​

触发源处理
视窗 scroll 事件rAF 合并调度 update(),同一帧多次滚动只重算一次
ResizeObserver同时观察视窗与轨道;任何尺寸变化(含窗口缩放、侧栏 / Inspector 折叠)后 update()
sessions.lengthnextTick 后 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 标签的宽度即时变化一致)。

阈值(标签数 N;实现上随窗口实时变化,无需硬编码):

布局视窗可用宽 W开始压缩 N₁转滚动 N₂
默认窗口 1200px(侧栏 264 + 检查器 310)626px4(非激活标签压至约 145px)6(190+5×90=640>626)
最小窗口 940px(检查器已隐藏,侧栏 264)676px4(非激活标签压至约 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:
    1. 标签不溢出时无箭头;开到溢出后箭头组出现;
    2. 单击右箭头位移约为 0.75 视窗宽;到达右边界后右箭头禁用变灰;
    3. 纵向滚轮映射为横向滚动;Ctrl+滚轮与触控板斜向滚动放行;
    4. 点击视窗外的标签,标签平滑滚入并完整可见;已可见的标签切换不产生滚动;
    5. 关闭标签至不再溢出,箭头组隐藏、视窗滚动位置复位;
    6. 拖动窗口缩放、折叠侧栏 / Inspector,状态在一帧内更新;
    7. 任意滚动位置下左右箭头分居两侧且可点击;未溢出时两个箭头同时隐藏,视窗占满标签栏;
    8. 名称被截断的标签悬停出现完整名 tooltip,短名称悬停无 tooltip;
    9. 第 4 个标签起非激活标签压缩(激活标签保持 190px);第 6(最小窗口下第 7)个标签起非激活标签压到 90px 并出现箭头;
    10. 在标签上按住左键拖动可横向滚动(光标 grabbing、无文本选中);位移 ≤5px 松手仍按点击处理;拖完松手不触发标签切换或关闭; 10.1 右缘只露出前半部分的标签,点击激活后自动滚到完整可见(× 与右缘全部进入视窗),不再残留被遮区域;左缘半露标签同理; 10.2 激活标签在任意滚动位置底部绿条连续、完整、无折断、无端头压暗;
    11. 所有标签的关闭按钮贴右缘、纵向对齐成一列;短名称与长名称(省略号)标签 × 位置一致;
    12. 原 + 按钮、quick-menu 与空态引导均无残留;标签右键、中键关闭、inline 重命名三项既有功能不回归;
  • 手工验证:macOS(触控板 + 普通鼠标)与 Windows(普通鼠标 + Shift+滚轮)各完整走查一遍。

9. 未决问题 ​

  • Ctrl+Tab / Ctrl+Shift+Tab 键盘循环切换标签:终端区按键由 xterm 捕获,需通过其 attachCustomKeyEventHandler 专门接线并处理与终端内快捷键的冲突,滚动部分可直接复用 §6.4,本期暂不实现。