首页
/ tldraw 移动端评论布局实战:forceMobile 与基于 Visual Viewport 的面板定位

tldraw 移动端评论布局实战:forceMobile 与基于 Visual Viewport 的面板定位

2026-09-06 19:10:58作者:钟日瑜

本文以 tldraw 示例 collaboration/commenting-mobile 为核心,讲解如何让画布评论(commenting)以移动端布局呈现:给 <Tldraw> 传入 forceMobile 可在任意屏幕宽度下锁定移动断点,而移动端布局的关键在于线程弹层(thread popover)与新建评论的输入框(composer)不再使用固定偏移,而是相对"图钉位置 + 可视视口(visual viewport)"动态定位,在软件键盘收缩视口时自动上移而不是被键盘遮挡。读完本文,你可以完整复现该示例,并理解 breakpoints.tsxmobile-placement.tsvisual-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 viewportthe 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 commentingswap in your own key here.
				licenseKey={getLicenseKey()}
				store={store}
				tools={COMMENT_TOOLS}
				overrides={[commentToolOverrides]}
				components={components}
			/>
		</div>
	)
}

关键配置项说明:

配置 作用
commentSchemaRecords 注册进 createTLSchema 评论以 comment-threadcomment 记录形式存入编辑器自己的 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.tsxBreakPointProvider 中:

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.

定位约束的四个边界来自 getVisibleViewportvisual-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))

可以拆出三个值得注意的设计:

  1. 横向对称钳制nextLeft 把面板水平方向夹进可视区域,实现"选一侧把整个面板留在屏内"的效果;
  2. 纵向只上提不下推:键盘只会从底部遮盖内容,所以面板的 top 只能向上修正(bottom - h 之下才上提),Math.min(y, ...) 保证面板永远不会被推到比自然位置更靠下——iOS 聚焦输入时可能滚动页面抬高 top,这个上界把这类"虚假上升"直接压掉;
  3. 坐标系换算:面板坐标是容器相对(container-relative),而 visual viewport 是窗口相对(window-relative),中间通过 container.getBoundingClientRect() 做差值换算。

桌面端不受影响:enabledfalse 时直接 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 监听面板元素;
  • 可视视口变化(键盘开合、双指缩放)或窗口尺寸变化——由 visualViewportresize/scrollwindow.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.tsvisual-viewport.ts:线程弹层与输入框以 visual viewport 为约束做方向选择与上下钳制,配合 ResizeObservervisualViewport 事件实现键盘弹出时的"骑升"行为。这套机制对任何需要"锚定在画布上的浮层在移动端不被键盘遮挡"的场景都是可直接参考的模式。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.13 K
2.75 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
857
1.35 K
docsdocs
暂无描述
Markdown
897
5.8 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
529
593
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
915
1.83 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.58 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.35 K
1.46 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.01 K
515
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
547
388