侧边栏一开,AI 画的东西就被挡住了:给白板画布加「真实可见区」计算和相机补偿
这两天 AI 白板类应用又多了好几款,个个都号称"AI 直接在画布上创作"。但有个体验细节很少有人提:AI 生成的图,经常一落地就被右侧的聊天面板挡住一半------用户还得手动把它拖出来,才能看到全貌。
我没去调 prompt,也没去换生成模型。我干了另一件事------去翻了一个开源无界白板(boundless)的代码,看它是怎么修这个问题的。它的解法跟 AI 半毛钱关系没有,纯粹是画布工程:算出"用户真正看得见的区域",再让相机跟着面板走。
看完我可以负责任地告诉你三件事。
第一,"可见区"不等于"画布区"------停靠面板占掉的那一块,必须从计算里扣掉,否则 AI 内容的默认落点永远偏右,藏到面板后面。
第二,面板打开的瞬间,相机要跟着往反方向平移半个面板宽------否则原来居中的内容会被面板吃掉。Excalidraw 也是这么干的。
第三,启动恢复时千万别补偿------从文件里恢复的相机,本来就是按"面板开着"的状态调好的,再补一次就双重偏移了。
1. 学习目标
读完这篇,你会给任何带停靠面板的画布应用加上三样东西:一个"真实可见区"计算函数、面板开合时的相机补偿、启动恢复时的防双重偏移。方法跟框架无关,我用 boundless 的 Rust 代码做参照,你照着思路搬到自己的项目里就行。
2. 前置知识:屏幕坐标和世界坐标
先给大家科普两个概念,画布应用里天天打交道:
屏幕坐标 是像素,左上角是原点,往右往下长。世界坐标是画布自己的坐标系,内容存在哪就是哪,跟窗口大小没关系。
中间连着这两套坐标的,是相机 :它记住"我现在看的是世界坐标的哪一块、缩放了多少"。boundless 的换算函数长这样(src/camera.rs):
rust
// 屏幕点 → 世界点:减画布原点,除以缩放,加相机中心
WPoint {
x: (p.x - canvas_origin.x).to_f64() / self.zoom + self.x,
...
}
翻译成人话:相机就是屏幕和世界之间的翻译官。 后面所有的"可见区",本质上都是"屏幕上的矩形,经过相机翻译,变成世界坐标里的矩形"。
3. 分步骤实操
步骤 1:定义"真实可见区",先扣面板
最容易犯的错误,是直接拿整张画布的尺寸算中心。boundless 旧实现就是这么写的:
rust
// 旧实现:直接用整张 canvas_bounds
let vp = self.canvas_bounds.size;
问题在哪?右侧 AI 面板是停靠覆盖 在画布上的------它占了右边缘几百像素,但 canvas_bounds 不知道这回事。于是 vis.center() 算出来的是整画布的中心,AI 生成的图片默认落点(x.unwrap_or(vis.center().x - w/2.0))就偏右,正好藏到面板后面。
新实现改成了三层扣除(src/board.rs:2725 的 viewport_bounds):
- 以
canvas_bounds为基------它在 render 时已被更新为画布元素的实际布局区域,左侧目录面板的内缩已经通过布局自动扣掉了; - 如果 AI 面板开着且不在演示模式,从宽度里减去面板的实时 宽度:
b.size.width = (b.size.width - px(panel_w)).max(px(1.0)); - 演示模式下不扣------演示时面板不占位。
然后 visible_world_bounds(src/board.rs:2846)把这个屏幕矩形的左上、右下两个角经相机翻译成世界坐标,拼成世界矩形:
rust
fn visible_world_bounds(&self, cx: &mut Context<Self>) -> WBounds {
let origin = self.canvas_origin();
// viewport_bounds 已扣除左侧目录内缩与右侧停靠的 AI 面板:
// AI 的默认落点会居中在用户真正看得见的区域,不会藏到面板后面。
let vp = self.viewport_bounds(cx);
let tl = self.camera.screen_to_world(vp.origin, origin);
let br = self.camera.screen_to_world(
point(vp.origin.x + vp.size.width, vp.origin.y + vp.size.height),
origin,
);
WBounds::from_corners(tl, br)
}
注意签名里多了个 cx 参数------旧实现不需要它,因为旧实现不查面板状态;新实现要读 AI 面板的实时宽度,必须进上下文拿。这就是 commit 里"改用真实可见区"的全部。
步骤 2:所有"默认落点"统一吃这一个函数
算出真实可见区还不够,得让所有往画布上放东西的地方都用它。boundless 里一共四处:
- AI 图片默认落点(
CanvasOp::AddImage,src/board.rs:1236):AI 没指定坐标时,居中在可见区; - AI 位图画布默认落点(
CanvasOp::AddCanvas,src/board.rs:1268):同上; - 粘贴/插入图片(
insert_image_bytes,src/board.rs:2813):居中于可见区,尺寸上限为可见区 45%; - 插入位图画板(
insert_canvas,src/board.rs:2863):480×320 世界单位,上限为可见区 60%。
有人可能会问:为什么非要统一成一个函数?因为面板宽度是实时可调的(后面步骤 5 会讲),散落在各处的"中心"算法迟早有人改漏一个。一个函数,一个真相。
步骤 3:面板打开时,相机补偿半个面板宽
光修落点还不够。用户正在看画布正中央的内容,这时打开右侧面板------可见区突然变窄,原来居中的东西现在偏右,可能一半藏到面板后面。
解法:打开面板的瞬间,把相机往左平移半个面板宽(src/board.rs:2294 的 open_ai_panel):
rust
if compensate && !compact {
let w = panel.read(cx).width();
self.camera.pan_by_screen(px(-w / 2.0), px(0.0));
}
翻译成人话:面板从右边吃掉了 w 宽的可见区,相机就往左跟 w/2,让原来的中心点在新的、变窄的可见区里仍然居中。 缩放不动------没必要先缩再恢复,直接平移最干净。代码注释里还写了一句"Excalidraw does the same",说明这不是拍脑袋,是行业里的通行做法。
pan_by_screen 本身也很简单(src/camera.rs):屏幕像素位移除以缩放,换算成世界坐标位移,加到相机中心上。
步骤 4:面板拖拽调宽时,实时跟
boundless 的 AI 面板是用户可拖拽调宽的:左侧 4px 拖拽柄,宽度钳制在 280~640px,默认 360px(src/ai/panel.rs)。面板变宽 = 可见区变窄,相机就得跟着动。
所以拖拽的每一帧,都按位移量的一半补偿相机:
rust
board.camera.pan_by_screen(px(-delta / 2.0), px(0.0))
跟步骤 3 是同一个数学,只是从"一次性"变成了"逐帧"。注释里明说跟 open/close 的补偿一致------同一个公式,三处复用(打开、关闭、拖拽)。
步骤 5:启动恢复时,别补偿
这是最容易写错的一步,也是这个 commit 里最妙的一笔。
打开面板的逻辑被拆成了一个带参数的函数:open_ai_panel(compensate: bool, ...)。用户手动切换时传 true;但启动时传 false:
rust
if chat_open {
// 相机刚从棋盘文件恢复,本来就是按"面板开着"调好的------直接开,不平移。
view.open_ai_panel(false, window, cx);
}
为什么?启动时相机的值是从棋盘文件里读出来的,而保存文件时的相机,本来就是在"面板开着"的状态下调好的。如果启动时再补偿半个面板宽,画面就会往左多偏一次------双重偏移。
旧代码走的是 toggle_ai_panel,打开就无脑平移,启动时也会中招。新代码把"打开"和"要不要补偿"解耦,用一个 bool 参数把两种场景分开。doc 注释把道理写得明明白白:mid-session toggle 需要补偿(相机是按无面板状态调好的),startup 不需要(相机恢复时已经是面板状态)。
4. 为什么这样设计
三条设计取舍,值得细品:
第一,补偿只动平移,不动缩放。 有些实现是"面板打开 → 缩小画布 → 面板关闭 → 恢复缩放",来回跳很晕。直接平移半个面板宽,内容大小不变,用户感知最小。
第二,compact 浮动条不补偿。 面板有个极简模式,是悬浮在底部的,不占画布空间------不占空间就不扣,if compensate && !compact 里写得很清楚。补偿的依据是"实际占了多少空间",不是"面板开没开"。
第三,面板宽度永远取实时值。 ai_panel_width 的注释写的是"its live, user-resizable value"------用户拖过的宽度,下次打开、关闭、算可见区都按拖过的值来,不用默认值 360 糊弄。面板关着的时候才回退到默认值。
5. 避坑清单
- 别直接拿整张画布算中心。 停靠面板覆盖的那块,布局上属于画布,视觉上不属于。凡是"默认落点",先问一句"用户真看得见吗"。
- 启动和手动切换是两条路径。 共用一个 toggle 写补偿,启动恢复必中双重偏移。用参数把场景分开,或者干脆写两个函数。
- 演示模式别扣面板。 面板在演示时不占位,扣了反而算错。条件里记得排除。
- 拖拽调宽要逐帧补偿。 只处理 open/close 不管拖拽,用户一拖面板,内容还是会被吃掉一半。
- 补偿公式三处统一。 打开、关闭(反向)、拖拽------同一个
pan_by_screen(±w/2),别各写各的。
6. 给开发者的建议
- 画布应用里所有"默认落点"走同一个可见区函数。 AI 生成、粘贴、插入、新建------入口再多,中心算法只有一个。
- 面板开合的相机补偿,只平移不缩放。 用户对"内容变小了"比"内容挪了一点"敏感得多。
- 把"为什么"写进注释。
compensate这个 bool 参数,不看注释没人知道启动时为什么传 false。下一个接手的人(很可能就是三个月后的你)会感谢这三行字。 - 面板宽度可调时,用实时值。 默认值只在面板关闭时兜底,开着的时候永远读 live width。
一句话收束:用户看不见的区域,对 AI 来说就不存在------落点算法里没有的东西,prompt 写得再好也落不进去。