Skip to main content
返回博客
iOSSafariWebKitLexicalcontenteditableDebugging

四个 bug 一起杀死了 iOS 上的文本选择——而且每一个都掩盖着下一个

放在 vaul 抽屉里的 Lexical 编辑器在哪里都正常,唯独在真实 iPhone 上不行:选择手柄拖不动,滚动在边缘卡死,工具栏沉到键盘下面。靠猜什么也修不好,因为根本不存在唯一的元凶——是四个相互独立的问题,每一个都掩盖着下一个。真正起作用的是二分法试验台:同一副骨架,为每个嫌疑对象设一个开关,在实体设备上验证。这是完整的追凶过程——毒害选择手势的 href、vaul 的拖拽处理器、我们自己的 tap-focus、在手指下方移动的容器——外加滚动锁死的解剖,以及让 rubber-band 回弹手感如原生般顺滑的三次一次性 scrollTop 写入,包括在回弹进行中接住一次滑动。

发布于 2026年9月5日15 分钟阅读

landee 的移动端文本编辑器是放在 vaul 抽屉里的 Lexical:点一下文本块,全屏抽屉从底部升起,编辑完点对勾关闭。在桌面端和模拟器里一切正常。在真实 iPhone 上却是一场有着多张独立面孔的灾难:选择手柄——iOS 用来拉伸选区的那两个蓝色圆点——完全拖不动,或者要按住大约两秒才能拖;把右手柄往左拖,整个选区直接消失;滚动在底部卡死,必须再往下拽一下才能「解开」;每当设备在键盘上方放出附加工具条,工具栏就沉到键盘下面。

这是一篇治愈过程的编年史——重点是路径,而不只是修复本身,因为能迁移到你项目里的正是路径。核心教训:当 iOS 上「选择不工作」时,不存在那个唯一等着被找到的元凶。它们有四个,彼此独立,而且每一个都掩盖着下一个——修好一个,症状恰好变化到让下一个假设看起来是错的。

技术栈:Next.js 16、React 19、Lexical 0.50、基于 vaul 的抽屉。以下所有内容都在真实 iPhone 上用生产构建验证过;代码片段就是上线的代码,只删减到要点。

失败的方法,和奏效的方法

最初几个小时都花在猜测上:也许是抽屉的 transform 图层,也许是滚动容器上的 CSS 遮罩,也许是 Lexical 的选择命令。每个猜测都产出一个貌似合理的补丁,而设备上毫无变化。其中两个补丁后来被证明本身就是 bug:一个是给 Lexical 的 SELECTION_CHANGE_COMMAND 加的消音器,另一个是拦在 Selection.prototype 写方法上的挡板——它让输入落到行首,因为点按不再传递光标位置。

奏效的是隔离试验台上的二分法:一个调试页面,骨架与编辑器完全相同——同一个 vaul 抽屉、同一根列、同一个滚动容器——并为每个嫌疑对象设一个开关按钮。裸的 contenteditable 在那里完美无缺。每个开关加回一层,直到某样东西坏掉。在真机上四轮下来,恰好定罪四个,其余全部无罪释放——body 锁、遮罩、transform 图层、对话框角色——否则这些嫌疑对象还要被「修理」好几天。

两条约束保证了试验台的诚实。只有实体设备算数:模拟器既没有真实键盘,也没有设备加在键盘上方的附加面板,而一半的 bug 恰恰住在那里。以及只有生产构建算数:开发构建太重,WebKit 会因内存不足杀掉标签页,水合还没完成一切就已经显得坏了。

元凶一:contenteditable 里链接上的 href

对 iOS 来说,<a href> 即便在可编辑文本里也是可交互的:触摸会启动「点击链接」手势,而那个手势优先级高于选择手势——选择手柄一碰到链接,选区就崩塌。同样的锚点没有 href 就只是文本。-webkit-touch-callout: none 救不了。而且不带任何编辑器库的裸 contenteditable 中毒方式一模一样——所以 Lexical 是无辜的。

解法是在 Lexical 渲染或更新链接节点后,立刻从编辑器的活 DOM 中移除 href。任何地方都没有损失:编辑模式下导航本来就被禁用,URL 存在 Lexical 节点里、照常可编辑,而保存的 HTML 依然带着 href——导出走 exportDOM,它构建自己的元素,从不接触活 DOM。

text-editor.tsx
// In the editor's live DOM links live without href — otherwise iOS
// kills the selection handles. The saved HTML is untouched: exportDOM
// builds its own elements and never sees this DOM.
useEffect(() => {
  const strip = (keys: Map<string, unknown>) => {
    for (const [key, kind] of keys) {
      if (kind === "destroyed") continue;

      const dom = editor.getElementByKey(key);

      if (dom instanceof HTMLAnchorElement) dom.removeAttribute("href");
    }
  };

  const unregisterLink = editor.registerMutationListener(LinkNode, strip, {
    skipInitialization: false,
  });
  const unregisterAuto = editor.registerMutationListener(AutoLinkNode, strip, {
    skipInitialization: false,
  });

  return () => {
    unregisterLink();
    unregisterAuto();
  };
}, [editor]);

元凶二:vaul 挂在抽屉内容上的拖拽处理器

即便设置了 handleOnly,vaul 依然把「拖拽关闭」的逻辑挂在抽屉的内容上——正是它杀死了手柄的抓取。证据是试验台上的一张矩阵:把带链接的 Lexical 放进同样几何形状的假抽屉——完美;放进真 vaul——死掉。然后一次改一样:剥掉 vaul 的样式(transformwill-changetouch-action)——还是死的;去掉对话框角色和 aria 属性——死的;解锁 body——死的;把事件静音、不让它们到达 vaul 的处理器——完全活了。

静音用的是 stopPropagation 而不是 preventDefault:浏览器和 Lexical 什么都听得见,因为它们的监听器在树里更深的位置——只有 vaul 变聋。而且必须同时覆盖 pointer 和 touch 事件:矩阵显示只静音 pointer 不够——只要 vaul 还听得到 touch,手柄就还是死的。

text-editor.tsx
{/* stopPropagation, not preventDefault: the browser and Lexical hear
    everything (their listeners are deeper in the tree) — only vaul's
    drag logic on the drawer content goes deaf. Both pointer AND touch:
    pointer-silence alone leaves the handles dead. */}
<div
  onPointerDown={(e) => e.stopPropagation()}
  onPointerMove={(e) => e.stopPropagation()}
  onPointerUp={(e) => e.stopPropagation()}
  onPointerCancel={(e) => e.stopPropagation()}
  onPointerOut={(e) => e.stopPropagation()}
  onTouchStart={(e) => e.stopPropagation()}
  onTouchMove={(e) => e.stopPropagation()}
  onTouchEnd={(e) => e.stopPropagation()}
>
  {/* the editor column */}
</div>

元凶三:我们自己的 tap-to-focus

一个辅助 hook 在每次短于 300 毫秒的触摸上都强制调用 focus()——而快速抓住选择手柄恰恰就是一次短触摸。这解释了那个传奇症状「按住两秒它才能拖」:长按不是点按,focus() 从不触发,拖拽因此存活。解法只有一行——仅当编辑器还没有焦点时才调用 focus()

元凶四:在手指下方移动的容器

第一版架构会把整个抽屉缩放到键盘上方。选择过程中 iOS 会平移屏幕——抽屉跟着平移走,文字从手指下方逃开,WebKit 就放弃了拖拽。「显而易见」的修复——手指按下时冻结抽屉——以另一种方式失败:文字相对手指偏移了恰好平移的距离,选区落点偏了一行。

由此诞生的规则:只能移动没有被触摸的东西。抽屉和文字站满 layout 视口的整个高度——iOS 上键盘并不压缩 layout,它只是盖住底部。具体做法:

  • 只有工具栏追着键盘跑;文字的底部留白通过一个 CSS 变量(--keyboard-inset)传递——什么都不移动。
  • 帧循环(requestAnimationFrame)而不是事件:选择时 iOS 的平移没有可靠的 visualViewport 事件,而键盘关闭后 offsetTop 有时永远不复位(iOS 26 的回归)。每帧看一眼的循环不需要任何信号。
  • 样式绕过 React 直接写入 DOM——走状态的话,每一个平移帧都会重渲染整棵 Lexical 树。
  • 滚动的是包裹层,不是 contenteditable。Lexical 官方 playground 也是这么做的(editor-scroller):一个自己滚动自己的聚焦 contenteditable 是 WebKit 已知最糟的配置——手势时而滚动,时而开始选择,时而喂给页面。
  • 打开半秒后,从抽屉上移除 vaul 的 transformwill-changetouch-action:合成图层会偏移选择手柄的触摸区域,而打开动画已经不再需要这些样式。

工具栏在键盘之上——以及在 iOS 放在键盘之上的一切之上

在 iPhone 上「100svh 减键盘」是谎言:地址栏拿走自己的一块,设备还喜欢在键盘上方放附加面板。真相只有 visualViewport 知道,工具栏需要的数字是 layout 视口底部被占用了多少:

text-editor.tsx
// Inside the requestAnimationFrame loop. Layout coordinates — the same
// space the toolbar's absolute position lives in. On iOS the keyboard
// never compresses layout; it only covers the bottom of it.
const inset = Math.max(
  0,
  Math.round(window.innerHeight - vv.height - vv.offsetTop),
);

toolbarHost.style.bottom = `${inset}px`;
drawer.style.setProperty("--keyboard-inset", `${inset + toolbarHeight}px`);

一个细节耗掉了一个晚上。当焦点移进工具栏自己的输入框——字号、链接地址——iOS 会切换键盘类型,visualViewport 会在几十帧里报告过渡值。立即应用它们意味着工具栏一头扎进键盘下面;把循环整个冻结(上一次的尝试)则让它恰好在高度真正变化时失明。折中方案:焦点在工具栏里时,新值必须稳定保持十帧才被应用;其余情况立即生效,工具栏跟着键盘动画一起走,而不是追赶它。

聚焦那些输入框还需要单独的技巧。当固定全屏容器里的真实 input 获得焦点时,iOS 会滚动页面以便把输入框居中——页面无处可滚,布局被撕裂:抽屉滑走,工具栏消失。vaul 在自己的焦点拦截器里治这个病,而我们刚刚把它静音了——所以手工复刻这个疗法:取消原生聚焦,用 transform 把输入框抛到高处,手动聚焦,下一帧放回。Safari 相信输入框「在上面」,就不再碰页面。

text-editor.tsx
const onTouchEnd = (event: TouchEvent) => {
  const target = event.target as HTMLElement;

  if (
    !(target instanceof HTMLInputElement) ||
    target === document.activeElement
  ) {
    return;
  }

  // Cancel the native focus (it drags the page scroll along) and focus
  // ourselves while the field is "up top".
  event.preventDefault();
  target.style.transform = "translateY(-2000px)";
  target.focus();
  requestAnimationFrame(() => {
    target.style.transform = "";
  });
};

// passive: false — without it preventDefault has no power.
toolbarHost.addEventListener("touchend", onTouchEnd, { passive: false });

滚动锁死:两个原因都是我们自己的

滚动在底部「锁死」:向上的手势失灵,必须再往下拽一下,像是解钩。这一次带着真实键盘的试验台无罪释放了全部几何结构(垫块、悬浮工具栏、抽屉本身),定罪两样东西,而且都是我们自己的:

  • 键盘留白住在 contenteditable 里面。在底部,键盘上方可见的那条带子是一块空的可编辑区域——而 iOS 把对聚焦 contenteditable 的触摸当作操作文本,不当作滚动。给键盘留的空间必须是 contenteditable 之后的一个独立不可编辑块——不能是它的 padding。
  • 我们自己的「保险」,本来是为第一个原因造的。在边缘附近 scrollTop 以小数游走,逐帧看门狗不停用程序化写入「纠正」它——而连续的程序化滚动写入会杀死人的手势。键盘一打开,保险自己就成了锁。

规则:边缘上永远不要 preventDefault,永远不要 scrollTop 看门狗。一个普通的 overflow-y-auto 包裹层,垫块放在 contenteditable 外面,滚动就完美无缺——再加 overscroll-contain,让边缘手势不去喂页面,因为 iOS 会为了聚焦的输入框把页面变成可滚动的。

text-editor.tsx
<div
  ref={scrollerRef}
  className="min-h-0 flex-1 overflow-y-auto overscroll-contain"
>
  <ContentEditable className="p-5 outline-none" />

  {/* Room for the keyboard as a SEPARATE non-editable block, not as
      padding of the contenteditable: editable padding at the bottom
      turns the scroll gesture into "working with text" and locks the
      scroller. */}
  <div aria-hidden style={{ height: "var(--keyboard-inset, 0px)" }} />
</div>

三次一次性写入:完整的 rubber-band 协议

锁消失后,剩下一族怪癖——三个疗法最终都是单次 scrollTop 写入。从不是循环,从不是看门狗。第一:停在精确边缘(0 或 max)的滚动容器有时会「粘住」——反向手势失灵,直到你往边缘外再拽一下。经典疗法十年前就有文档(iNoBounce 及同类):在 touchstart 时把 scrollTop 从边缘推开一像素——赶在 WebKit 判定这是什么手势之前。边缘变得不可到达,「粘住」状态根本不会形成。

第二:惯性可能在手指离开后把滚动容器送进边缘。touchstart 的推移对此恰好晚一次滑动。而从 scroll 处理器里立即推移会掐断原生弹簧——边缘感觉像撞墙。所以安定推移是延迟的:当 scroll 事件静默 140 毫秒、无手指按下、位置又是精确边缘时,一次安静的写入把它向内挪一像素。

第三,最难的:回弹动画期间的滑动直接被吞掉。这是平台行为——rubber-band 动画播放期间,WebKit 不把手势挂到任何东西上,直到动画播完。你滑了一下,什么都没动,只好再滑一次。找到的所有文章都不提供中断方式;线索终结于「等它播完」。

入口找到了:回弹期间 iOS 会暴露越界的 scrollTop——顶部为负,底部大于 max。这让时机可以被严格检测:st < 0 || st > max 是真正的 overscroll,绝不是安静停在边缘。在这个状态下的 touchstart 里,一次夹取写入就打断动画——同一个手势当即抓住滚动容器。附带一个保险:只对第一根手指生效(touching === 1),因为动画只在没有手指按住时才会播放,第二根手指不得把文字从第一根手指下面抽走。

text-editor.tsx
const nudge = () => {
  const max = scroller.scrollHeight - scroller.clientHeight;

  if (max <= 1) return;

  if (scroller.scrollTop <= 0) scroller.scrollTop = 1;
  else if (scroller.scrollTop >= max) scroller.scrollTop = max - 1;
};

let touching = 0;
let settleTimer = 0;

const touchBegin = () => {
  touching += 1;
  window.clearTimeout(settleTimer);

  const max = scroller.scrollHeight - scroller.clientHeight;
  const st = scroller.scrollTop;

  // Strictly OUT of bounds — a finger landing mid-bounce, not resting
  // at an edge. One clamped write interrupts the animation, and the
  // same gesture grabs the scroller. Only for the first finger: an
  // animation only plays when no finger held the scroller.
  if (touching === 1 && max > 1 && (st < 0 || st > max)) {
    scroller.scrollTop = st < 0 ? 1 : max - 1;
    return;
  }

  nudge();
};
const touchFinish = () => {
  touching = Math.max(0, touching - 1);
};

// Deferred, not immediate: an instant nudge mid-bounce cut the native
// spring short and the edge felt like hitting a wall. Wait for the
// scroll stream to go quiet, then one quiet write.
const onScroll = () => {
  if (touching > 0) return;

  window.clearTimeout(settleTimer);
  settleTimer = window.setTimeout(nudge, 140);
};

scroller.addEventListener("touchstart", touchBegin, { passive: true });
scroller.addEventListener("touchend", touchFinish, { passive: true });
scroller.addEventListener("touchcancel", touchFinish, { passive: true });
scroller.addEventListener("scroll", onScroll, { passive: true });
  • overflow-toggle 大锤(设 overflow: hidden、夹取、强制 reflow、还原)——在设备上被否决两次。它的第一版还会在边缘的安静触摸上开火(该用 < 0 的地方用了 <= 0),顺带弄坏了正常滚动。
  • overscroll-behavior: none——把问题连同弹簧一起消灭:惯性在边缘硬生生停死,边缘感觉像被砍断。

已证无罪——别再追猎

  • vaul 的 position: fixed body 锁
  • 滚动容器上的 CSS 渐隐遮罩
  • transform 图层本身(移除它是对的,但凶手不是它)
  • 抽屉的对话框角色、aria 和 data 属性
  • Lexical 本身——裸 contenteditable 能同时复现 href 之毒和滚动锁死
  • 给 Lexical 的 SELECTION_CHANGE_COMMAND 消音——毫无变化
  • 拦截 Selection.prototype 的写入——自己变成了 bug:点按不再传递光标,输入跑到行首

彩蛋敌人:原生选区对阵 URL 输入框

编辑链接自带一个冲突:iOS 的原生选区只活在聚焦的编辑器里,而 URL 输入框自己需要焦点——两者无法共存。所以在编辑期间,选中的文本被包进 Lexical 的 MarkNode:DOM 里真实存在的高亮,根本不在乎焦点在哪。Lexical 官方 playground 用同一机制高亮评论目标。

选区本身则被持续保存,每次变化都存——和 Lexical 的 FloatingLinkEditorPlugin 一样的 lastSelection 模式——因为等按钮按下再存就晚了:点击工具栏按钮会先让选区崩塌。应用时,解除标记会在原位重建真实选区,剩下的交给 TOGGLE_LINK_COMMAND

结论

  • 「iOS 上选择不工作」不是一个 bug。有四个独立原因,每个掩盖着下一个——单假设调试在这种局面上无法收敛。
  • 二分法胜过演绎。为每个嫌疑对象设一个开关的隔离试验台,把数天的猜测变成四轮真机验证——还无罪释放了那些否则要被「修理」好几天的嫌疑对象。
  • 只有实体设备说真话:模拟器没有真实键盘和附加面板,而开发构建在 bug 现身之前就先死于内存。
  • 只移动没有被触摸的东西:静止的抽屉、帧循环上的工具栏、走 CSS 变量的底部留白。
  • 永远不要用连续写入或边缘 preventDefault 对抗 iOS 滚动——两者都会变成它们本要防止的那把锁。每个幸存的疗法都是在精确检测的时机上的一次 scrollTop 写入。
  • 完整的 rubber-band 协议:静止边缘上的 touchstart 推移、惯性后的延迟安定推移、回弹中途 touchstart 的夹取——之所以可检测,是因为 iOS 暴露了越界的 scrollTop。

发现错误了吗?

本文里有事实错误、别扭的翻译,或者哪里读起来不对?告诉我——用你自己的语言。