中间画布:任意分屏布局

把中间区域从「标签页 + 最多两格分屏」升级成一块可以随意切分的画布:左右、上下、四宫格、九宫格、十六宫格,或者不规则的组合都可以。本页上半部分是可以直接上手操作的原型,下半部分是数据模型、交互规则和实施计划。

原型怎么玩: 把顶部 tab 拖到某一格的边缘,就在那一侧切出新格;拖到格子中央,就替换这一格的内容(被换下来的内容回到 tab 栏)。拖动时 tab 本身跟着鼠标走,不透明,颜色介于当前 tab 和其他 tab 之间;拖到窗口外松手就是新开窗口。 拖格子的标题栏可以挪动整格,拖回 tab 栏就把它移出画布。格子之间的缝隙可以拖动来调整大小,双击缝隙会把两边恢复成等分。 快捷键:⌘\ 向右切分,⌘⇧\ 向下切分,⌘⇧↩ 放大或还原当前格,⌘W 关闭当前格,⌥⌘←↑→↓ 在格子之间移动焦点。
预设布局
+

1. 目标与边界

2. 数据模型:n 叉切分树

布局用一棵树表示。叶子是格子(pane),中间节点是切分(split)。一个切分节点可以有任意多个子节点,每个子节点带一个比例值。这样九宫格就是「一个纵向切分,下面挂三个横向切分,每个横向切分再挂三格」,不需要二叉树那样层层嵌套。

type LayoutNode =
  | { kind: "pane";  id: PaneId; content: TabRef | null }        // null = 空格
  | { kind: "split"; id: string; dir: "row" | "col";
      children: LayoutNode[]; sizes: number[] };                 // sizes 之和为 1

type CanvasLayout = {
  root: LayoutNode;
  focusedPaneId: PaneId;
  zoomedPaneId?: PaneId;          // 放大某一格时其余格不卸载,只是隐藏
};

每次修改之后都要做一遍规范化,这样树永远保持最简形状:

这个模型取代现在 center-tab-groups.ts 里的 MAX_CENTER_TAB_GROUP_MEMBERS = 2 分组模型。旧的两格分组可以无损迁移:迁移后就是一个 row 切分下挂两个格子。

3. 交互规则

3.1 拖动 tab(原型已实现)

松手位置结果
tab 栏内重新排序,和现在一样。
某一格中央方块以外的区域(按离哪条边最近分成左、右、上、下四块)在这一侧切出新格,被拖的内容放进去;它随即从 tab 栏里消失,也就是「融入」画布。
某一格的中央方块(宽高各占中间 40%)替换这一格的内容,被换下来的内容回到 tab 栏。如果这一格是空格,就直接填进去。
画布最外沿,距离边框 6px 以内在整个画布的这一侧加一整列或一整行,而不是只切某一格。
右侧 Resources 面板和现在一样,把网页绑定成会话资源。
窗口以外的任何地方新开一个窗口承载这个内容。

拖动的视觉效果:被拖的 tab 就是那个元素本身,用 transform 跟着鼠标走,离开 tab 栏以后照样跟着,原来的位置留出空位。它完全不透明,背景色是 color-mix(in srgb, 当前tab色 50%, bg-tertiary),外加一圈细边和一层阴影。不再显示额外的蓝色「New window」提示条,落点的效果改由 drop 预览框直接表达。

3.2 格子本身

3.3 预设布局

单屏、左右、上下、主 + 2、四宫格、九宫格、十六宫格。套用预设时,已经在画布里的内容按阅读顺序依次填进新格子;内容多出来就退回 tab 栏,格子多出来就留成空格。

4. 尺寸与性能约束

内容类型最小宽 × 高格子太小时的处理
会话280 × 200隐藏侧边信息,输入框压缩成单行
网页320 × 200低于这个尺寸时原生视图冻结成截图
终端200 × 120xterm 自己重新计算行列数
文件或程序200 × 120—

再往下压,就进入「迷你态」:格子只显示标题和类型,点一下就放大。原型里把宽度小于 150px 或高度小于 110px 的格子当作迷你态来演示。

网页格子是难点。网页是 Electron 的原生 WebContentsView,永远盖在 DOM 上面,所以:
  1. 每个网页格子都要通过现有的 registerVisibleWebTabBounds 上报自己的 bounds,缝隙拖动时要按帧同步;
  2. 拖拽开始时,把所有网页格子冻结成 capturePage 截图并隐藏原生视图,松手后再恢复,否则 drop 预览框和被拖的 tab 会被网页挡住;
  3. 十六格全是网页时,同时存活的 WebContents 太多,需要一个上限(比如最多 8 个活跃,其余冻结成截图,点一下再激活)。

5. 持久化

6. 实施计划

阶段内容涉及文件
P0 拖动手感tab 本身跟着鼠标走,拖出 tab 栏也跟着;拖动时不透明、用中间色;去掉蓝色 New window 提示条;拖动时冻结网页视图。use-tab-pointer-drag.ts、center-tabs.module.css、center-tab-strip.tsx、check-center-tabs.mjs
P1 布局树新增 lib/tabs/canvas-layout.ts,包含纯函数的切分、移除、规范化、移动、预设、缝隙调整,并配完整单测;把现有两格分组迁移过来;渲染层改成递归的 CanvasNode 组件。center-tab-groups.ts、store/layout.ts、app-shell.tsx
P2 拖放分屏五向 drop zone 加画布外沿、drop 预览框,拖格子标题栏,拖回 tab 栏;同步修订「分屏只能从右键菜单进入」这条设计约束。tab-drag-coordinator.ts(resolveTabDropIntent 增加 pane zone 这种 intent)
P3 多网页格多个 WebContents 的 bounds 同步、活跃数上限、截图冻结。web-tab-pane.tsx、lib/desktop/*、desktop 主进程
P4 完善预设菜单、快捷键、放大、命名预设、持久化。—

7. 采用的交互约定

产品画布不显示常驻布局工具栏,也不预留工具栏高度。右键点击格子标题可使用预设、切分、等分和保存布局;拖放与快捷键直接可用。下方原型中的工具栏仅用于演示布局操作。

多格画布作为一个布局标签;同一内容只占一个格子;拖到格子中央替换内容,被替换的内容回到顶部标签栏。关闭最后一格后保留空布局,原内容仍在标签栏。

实现状态

代码实现:布局树与旧两格迁移、预设、空格选择内容、五向拖放与外沿插入、格子拖回标签栏、分隔线调整与双击等分、放大还原、快捷键、命名预设及窗口持久化。拖动时原标签保持不透明并跟随指针;原生网页显示截图,结束后恢复。最多同时显示 8 个原生网页视图,其余以截图显示,点击切换;这不终止后台网页进程。跨窗口传输包含布局树,并保留传输体积与结构限制。

渲染通过递归布局计算得到格子与分隔线坐标,内容宿主保持稳定,避免调整树结构时重新挂载文件编辑器、终端和会话。放大仅隐藏其他格子。

把窗格拖到顶部标签栏时,显示与画布停靠一致的蓝色圆角落点预览。提示覆盖标签栏实际范围,移出或取消时消失;在范围内松手后恢复为独立标签。