tldraw 移动端评论布局实战:forceMobile 与基于 Visual Viewport 的面板定位
本文以 tldraw 示例 collaboration/commenting-mobile 为核心,讲解如何让画布评论(commenting)以移动端布局呈现:给 <Tldraw> 传入 forceMobile 可在任意屏幕宽度下锁定移动断点,而移动端布局的关键在于线程弹层(thread popover)与新建评论的输入框(composer)不再使用固定偏移,而是相对"图钉位置 + 可视视口(visual viewport)"动态定位,在软件键盘收缩视口时自动上移而不是被键盘遮挡。读完本文,你可以完整复现该示例,并理解 breakpoints.tsx、mobile-placement.ts、visual-viewport.ts 中的定位机制与边界处理。
这个示例解决什么问题
在桌面端,线程弹层和评论输入框相对评论锚点(pin)使用固定偏移即可;但在真机上,软件键盘会收缩可视视口——注意它收缩的是 visual viewport,布局视口(以及 CSS 的 dvh)尺寸不变。此时若面板仍按固定偏移放置,就可能落到键盘后面、无法看见。
移动端的解法是:
- 线程弹层与输入框以图钉(pin)和 visual viewport 为参考定位,而不是固定偏移;
- 在图钉两侧中选择能把整个面板留在屏幕内的一侧;
- 键盘收缩视口时面板"骑"到键盘上方(ride up),而不是躲在键盘后面;
- 把评论放在画布边缘附近时,可以直观看到定位策略的自适应过程。
示例 README.md 原文描述即为此意:"In mobile mode the thread popover and the new-comment composer position themselves relative to the pin and the visual viewport rather than sitting at a fixed offset."
该示例与基础示例 Commenting 的配置完全一致,唯一差异是在编辑器上开启了 forceMobile,从而在任何屏幕尺寸下都展示移动端布局(真机上同一布局则由窄视口自然触发)。
完整示例代码与逐段说明
示例组件位于 CommentingMobileExample.tsx,完整代码如下:
import {
CanvasComments,
CommentAuthor,
CommentTool,
commentToolOverrides,
filterMentionMembers,
MentionMember,
} from '@tldraw/commenting'
import { getLicenseKey } from '@tldraw/dotcom-shared'
import { useMemo } from 'react'
import { commentSchemaRecords, createTLSchema, createTLStore, TLComponents, Tldraw } from 'tldraw'
import '@tldraw/commenting/commenting.css'
import 'tldraw/tldraw.css'
// A demo avatar image (inline SVG) so one author shows an image instead of a colored initial.
const ADA_AVATAR =
'data:image/svg+xml,' +
encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28"><rect width="28" height="28" fill="#0E9F6E"/><circle cx="14" cy="11" r="5" fill="#fff"/><ellipse cx="14" cy="24" rx="9" ry="7" fill="#fff"/></svg>`
)
// The people who can be @-mentioned. A real app would pull this from its own roster; the composer
// filters this list as you type after `@`. Ids match the author directory below.
const MEMBERS: MentionMember[] = [
{ id: 'me', name: 'You', color: '#EC5E41', you: true },
{ id: 'ada', name: 'Ada Lovelace', color: '#0E9F6E', image: ADA_AVATAR },
{ id: 'grace', name: 'Grace Hopper', color: '#4465E9' },
{ id: 'alan', name: 'Alan Turing', color: '#9C1FBE' },
]
// A tiny local user directory so the flow shows names, colors, and images instead of ids. A real
// app would resolve these from its own identity system.
const AUTHORS: Record<string, CommentAuthor> = Object.fromEntries(MEMBERS.map((m) => [m.id, m]))
const resolveAuthor = (id: string): CommentAuthor => AUTHORS[id] ?? { name: id }
// Region comments are off by default. With `enableRegions`, dragging the comment tool out draws a
// rectangle and anchors the comment to that area instead of a point or shape.
const COMMENT_TOOLS = [CommentTool.configure({ enableRegions: true })]
export default function CommentingMobileExample() {
// Comments are stored as `comment-thread` and `comment` records in the editor's own store.
// Registering `commentSchemaRecords` on the schema is all it takes to persist and sync them
// alongside shapes — no separate backend, so the whole flow runs in-memory here.
const store = useMemo(
() => createTLStore({ schema: createTLSchema({ records: commentSchemaRecords }) }),
[]
)
// `CanvasComments` reads those records reactively and draws the pins, threads, and composer.
// Mounting it in front of the canvas is the entire UI layer.
const components = useMemo<TLComponents>(
() => ({
InFrontOfTheCanvas: () => (
<CanvasComments
currentUserId="me"
resolveAuthor={resolveAuthor}
getMentionSuggestions={(query) => filterMentionMembers(MEMBERS, query)}
/>
),
}),
[]
)
return (
<div className="tldraw__editor">
<Tldraw
// `forceMobile` pins the mobile breakpoint on any screen size, so the mobile thread and
// composer placement is visible here on desktop. On a real device the same layout comes
// from the narrow viewport — the thread popover and composer position themselves against
// the visual viewport, riding up rather than hiding behind the software keyboard.
forceMobile
// Commenting is a licensed feature. Every feature is enabled in local development, but a
// deployed app needs a license key that includes commenting — swap in your own key here.
licenseKey={getLicenseKey()}
store={store}
tools={COMMENT_TOOLS}
overrides={[commentToolOverrides]}
components={components}
/>
</div>
)
}
关键配置项说明:
| 配置 | 作用 |
|---|---|
commentSchemaRecords 注册进 createTLSchema |
评论以 comment-thread 和 comment 记录形式存入编辑器自己的 store,随图形一起持久化与同步,无需独立后端 |
CommentTool.configure({ enableRegions: true }) |
区域评论默认关闭;开启后拖出评论工具可画矩形,把评论锚定到区域而非点或图形 |
commentToolOverrides |
评论工具需要的编辑器 overrides,与 Commenting 示例 保持一致 |
InFrontOfTheCanvas 插槽挂载 CanvasComments |
整个 UI 层就是这一处挂载;CanvasComments 响应式读取评论记录,绘制 pin、线程与输入框 |
resolveAuthor / getMentionSuggestions |
把作者 id 解析为姓名、颜色、头像;@ 提及建议由 filterMentionMembers 对成员列表做输入过滤 |
licenseKey={getLicenseKey()} |
评论是带 license 的特性:本地开发默认全部启用,部署的应用需要包含 commenting 的 license key |
forceMobile |
本示例与桌面版 Commenting 示例的唯一差异,见下节 |
forceMobile 如何锁定移动断点
forceMobile 的判定逻辑在 breakpoints.tsx 的 BreakPointProvider 中:
const breakpoint = useValue(
'breakpoint',
() => {
// This will recompute the viewport screen bounds changes...
const { width } = editor?.getViewportScreenBounds() ?? { width: getGlobalWindow().innerWidth }
const maxBreakpoint = forceMobile
? PORTRAIT_BREAKPOINT.MOBILE_SM
: PORTRAIT_BREAKPOINTS.length - 1
for (let i = 0; i < maxBreakpoint; i++) {
if (width > PORTRAIT_BREAKPOINTS[i] && width <= PORTRAIT_BREAKPOINTS[i + 1]) {
return i
}
}
return maxBreakpoint
},
[editor]
)
从源码结构看,断点由编辑器视口屏幕边界的宽度(editor.getViewportScreenBounds())计算得出,并按 constants.ts 中定义的纵向断点表匹配:
export const PORTRAIT_BREAKPOINTS = [0, 389, 436, 476, 580, 640, 840, 1023]
export const PORTRAIT_BREAKPOINT = {
ZERO: 0,
MOBILE_XXS: 1,
MOBILE_XS: 2,
MOBILE_SM: 3,
MOBILE: 4,
TABLET_SM: 5,
TABLET: 6,
DESKTOP: 7,
} as const
当 forceMobile 为真时,maxBreakpoint 被钳制为 MOBILE_SM(值为 3)。在宽桌面窗口下,宽度落不进前三个区间,循环直接结束并返回 maxBreakpoint,即断点恒为 MOBILE_SM——这就是"窗口怎么拉宽都保持移动布局"的实现原理。去掉 forceMobile,断点上限回到 PORTRAIT_BREAKPOINTS.length - 1(即 DESKTOP),布局改由容器实际宽度决定,与真实设备行为一致。
评论层如何判断"处于移动端"
评论包通过 mobile-placement.ts 中的 useIsMobileCommenting 决定走哪套布局:
export function useIsMobileCommenting(): boolean {
let breakpoint: number
try {
// The call is unconditional — the try only guards the provider's absence, not hook order.
breakpoint = useBreakpoint()
} catch {
return false
}
return breakpoint < PORTRAIT_BREAKPOINT.TABLET_SM
}
要点:
- 门限是
breakpoint < PORTRAIT_BREAKPOINT.TABLET_SM(即断点 0~4,移动端区间),与 tldraw 移动端工具栏、样式面板使用同一套断点门控,同样响应<Tldraw>上的forceMobile; try/catch并非处理异常逻辑,而是应对评论层可以脱离 tldraw 默认 UI 挂载(hideUi、自定义 UI)的场景——此时断点 Provider 不存在,useBreakpoint会抛错,评论层回退到桌面渲染(即移动端改造前的行为)。
由于 forceMobile 下断点恒为 3(MOBILE_SM < TABLET_SM),本示例在桌面浏览器中也判定为移动端,从而演示出手机上的布局与定位行为。
面板定位:从固定偏移到可视视口钳制
核心实现是同文件中的 useMobilePlacement(ref, base, enabled),签名与职责为:base 是面板期望的左上角(容器相对坐标),返回值是最终的 { left, top }。源码注释直接点明了问题根源:
The software keyboard shrinks the visual viewport while leaving the layout viewport (and CSS
dvh) untouched, so a panel placed at a fixed offset from its pin can end up behind the keyboard.
定位约束的四个边界来自 getVisibleViewport(visual-viewport.ts):
export function getVisibleViewport(win: Window): VisibleViewport {
const vv = win.visualViewport
const top = vv ? vv.offsetTop : 0
const left = vv ? vv.offsetLeft : 0
const width = vv ? vv.width : win.innerWidth
const height = vv ? vv.height : win.innerHeight
return { top, left, width, height, bottom: top + height, right: left + width }
}
它读取 window.visualViewport 并在不可用时回退到 innerWidth/innerHeight。注意保留了 offsetTop/offsetLeft:iOS 打开键盘时视口自身存在偏移,丢弃这个偏移会导致所有基于视口的测量错位。
useMobilePlacement 的钳制逻辑(VIEWPORT_MARGIN = 8 为四周安全边距):
const top = vp.top - cRect.top + VIEWPORT_MARGIN
const bottom = vp.bottom - cRect.top - VIEWPORT_MARGIN
const left = vp.left - cRect.left + VIEWPORT_MARGIN
const right = vp.right - cRect.left - VIEWPORT_MARGIN
const w = el.offsetWidth
const h = el.offsetHeight
const nextLeft = Math.max(left, Math.min(x, right - w))
// The keyboard only ever covers space from the bottom, so it can pull the panel up (when its
// bottom would be hidden) but must never push it down below its natural spot.
const nextTop = Math.min(y, Math.max(top, bottom - h))
可以拆出三个值得注意的设计:
- 横向对称钳制:
nextLeft把面板水平方向夹进可视区域,实现"选一侧把整个面板留在屏内"的效果; - 纵向只上提不下推:键盘只会从底部遮盖内容,所以面板的
top只能向上修正(bottom - h之下才上提),Math.min(y, ...)保证面板永远不会被推到比自然位置更靠下——iOS 聚焦输入时可能滚动页面抬高top,这个上界把这类"虚假上升"直接压掉; - 坐标系换算:面板坐标是容器相对(container-relative),而 visual viewport 是窗口相对(window-relative),中间通过
container.getBoundingClientRect()做差值换算。
桌面端不受影响:enabled 为 false 时直接 return { left: baseX, top: baseY },面板按固定偏移跟随相机,与移动端改造前完全一致。
何时重新计算位置
base 在平移(pan)时每一帧都会变化,源码用 ref 承载它来保持 update 回调稳定,避免观察者每帧拆建:
// The camera moves `base` every frame while panning. Reading it from a ref keeps `update` stable
const baseRef = useRef({ x: baseX, y: baseY })
// Re-place as the camera moves the panel's base point.
useLayoutEffect(() => {
baseRef.current = { x: baseX, y: baseY }
update()
}, [baseX, baseY, update])
// Re-place when the panel grows (replies, edits), when the visual viewport changes (keyboard,
// pinch-zoom), or when the window resizes.
useLayoutEffect(() => {
...
const ro = new ResizeObserver(update)
ro.observe(el)
const vv = win.visualViewport
vv?.addEventListener('resize', update)
vv?.addEventListener('scroll', update)
win.addEventListener('resize', update)
...
}, [container, ref, enabled, update])
也就是说,面板会在三类时机重新定位:
- 相机移动改变面板基点(每帧,经
baseRef触发); - 面板尺寸变化(回复变长、编辑内容)——由
ResizeObserver监听面板元素; - 可视视口变化(键盘开合、双指缩放)或窗口尺寸变化——由
visualViewport的resize/scroll与window.resize事件驱动。
本地运行与适用前提
- 该示例位于 examples 应用的
collaboration分组(apps/examples/src/examples/collaboration/commenting-mobile/),与 Commenting 示例共用同一套集成步骤:注册commentSchemaRecords→ 配置CommentTool+commentToolOverrides→ 在InFrontOfTheCanvas插槽挂载CanvasComments。 - 验证方式:在示例中放置一条靠近画布边缘的评论,观察弹层自动换边保持整板可见;在真机(或浏览器 DevTools 的设备模拟 + 触摸模式)上聚焦输入框,键盘弹出时面板会整体上移到键盘上方。
- 适用限制:评论(commenting)是带 license 的特性,本地开发环境默认启用全部特性,部署到线上需要包含 commenting 的 license key;
forceMobile只影响 UI 断点判定,真机上的移动布局不需要它,由窄视口自然触发。 - 相关延伸阅读:断点强制的通用用法见 force-mobile 示例,其结论与本例一致——
forceMobile把 tldraw 的 UI 钉在移动断点,窗口任意宽窄都保持紧凑移动排列。
小结
本示例展示的是"同一套评论集成,两种布局":代码层面只在 <Tldraw> 上多了一个 forceMobile 属性,其背后由 breakpoints.tsx 的断点钳制驱动;而移动端布局的实质差异集中在 mobile-placement.ts 与 visual-viewport.ts:线程弹层与输入框以 visual viewport 为约束做方向选择与上下钳制,配合 ResizeObserver 和 visualViewport 事件实现键盘弹出时的"骑升"行为。这套机制对任何需要"锚定在画布上的浮层在移动端不被键盘遮挡"的场景都是可直接参考的模式。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0627
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00