侧边栏一开,AI 画的东西就被挡住了:给白板画布加「真实可见区」计算和相机补偿

侧边栏一开,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):

  1. 以 canvas_bounds 为基------它在 render 时已被更新为画布元素的实际布局区域,左侧目录面板的内缩已经通过布局自动扣掉了;
  2. 如果 AI 面板开着且不在演示模式,从宽度里减去面板的实时 宽度:b.size.width = (b.size.width - px(panel_w)).max(px(1.0));
  3. 演示模式下不扣------演示时面板不占位。

然后 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. 给开发者的建议

  1. 画布应用里所有"默认落点"走同一个可见区函数。 AI 生成、粘贴、插入、新建------入口再多,中心算法只有一个。
  2. 面板开合的相机补偿,只平移不缩放。 用户对"内容变小了"比"内容挪了一点"敏感得多。
  3. 把"为什么"写进注释。 compensate 这个 bool 参数,不看注释没人知道启动时为什么传 false。下一个接手的人(很可能就是三个月后的你)会感谢这三行字。
  4. 面板宽度可调时,用实时值。 默认值只在面板关闭时兜底,开着的时候永远读 live width。

一句话收束:用户看不见的区域,对 AI 来说就不存在------落点算法里没有的东西,prompt 写得再好也落不进去。

相关推荐
Sammyyyyy1 小时前
Claude Haiku 5.5 与 GPT-6.1 Sol 选型拆解:20 倍价差不等于 20 倍总成本,附网关分流思路
人工智能·gpt
LaughingZhu1 小时前
Product Hunt 热榜 2026-10-10:Busabase、Zernio、Playground by Google Labs
人工智能·深度学习·神经网络·搜索引擎·百度
JWASX1 小时前
【agent 开发】Agent 智能体
大数据·人工智能·python
智购科技无人售货机工厂店1 小时前
玻璃瓶饮料破损率突然上升,排查发现是取货口缓冲垫老化了~YH
java·开发语言·人工智能·python·eclipse
骇客野人1 小时前
业务系统接入AI方案及全流程实施过程
人工智能
Jing_jing_X1 小时前
大模型只会输出token,是怎么“调用工具“的?
ai·agent·个人开发·ai应用开发
一木 之林1 小时前
Dify学习笔记 00 · 总览:56集四模块学习地图(从低代码平台到检索底座)
人工智能·计算机视觉·langchain
镜象科技1 小时前
中小学AI心理健康解决方案服务商怎么选?2026年选型参考
android·java·人工智能