Skip to main content
Назад к блогу
iOSSafariWebKitLexicalcontenteditableDebugging

Четыре бага убивали выделение текста на iOS — и каждый прятал следующий

Редактор Lexical в drawer-е vaul работал везде, кроме живого айфона: ползунки выделения не тянулись, прокрутка запиралась на краях, панель инструментов ныряла под клавиатуру. Догадки не лечили ничего, потому что единственного виновника не существовало — их было четверо, независимых, и каждый маскировал следующего. Сработал стенд бисекции: тот же каркас с переключателем на каждого подозреваемого, проверенный на физическом устройстве. Это полная охота — href, отравляющий выделение, обработчики перетаскивания vaul, наш собственный tap-focus, контейнер, едущий под пальцем, — плюс вскрытие замка прокрутки и три одноразовые записи scrollTop, сделавшие rubber-band родным, включая перехват свайпа посреди пружины.

Опубликовано 5 сентября 2026 г.15 мин чтения

Мобильный текстовый редактор в landee — это Lexical внутри drawer-а vaul: тап по текстовому блоку, снизу выезжает полноэкранный ящик, редактируешь, галочка закрывает. На десктопе и в симуляторе всё было хорошо. На физическом айфоне это была катастрофа с несколькими независимыми лицами: ползунки выделения — те два синих кружочка, которыми iOS даёт растянуть выделенное, — не тянулись вообще или только после удержания секунды две; потянешь правый ползунок влево — всё выделение пропадает; прокрутка запиралась на дне, пока не дёрнешь ещё ниже, будто «отцепив» её; а панель инструментов ныряла под клавиатуру всякий раз, когда устройство ставило над ней свою служебную полосу.

Это хроника того, как оно вылечилось — именно маршрут, а не только лекарства, потому что переносить на свои проекты стоит маршрут. Главный урок: когда «не работает выделение» на iOS, не существует одного виновника, которого нужно найти. Их было четверо, независимых друг от друга, и каждый маскировал следующего — починишь одного, симптомы изменятся ровно настолько, чтобы следующая гипотеза выглядела ложной.

Стек: Next.js 16, React 19, Lexical 0.50, drawer на vaul. Всё ниже проверено на физическом айфоне на продакшн-сборках; фрагменты кода — тот код, что уехал в прод, обрезанный до сути.

Метод, который провалился, и метод, который сработал

Первые часы ушли на догадки: может, transform-слой drawer-а, может, CSS-маска на скроллере, может, команды выделения Lexical. Каждая догадка давала правдоподобную заплатку и ноль изменений на устройстве. Две из тех заплаток позже оказались багами сами: глушитель команды SELECTION_CHANGE_COMMAND в Lexical и заслон на методах записи Selection.prototype, из-за которого набор шёл в начало строки — тап переставал доносить каретку.

Сработала бисекция на изолированном стенде: отладочная страница с тем же каркасом, что в редакторе, — тот же drawer vaul, та же колонка, тот же скроллер — и кнопка-переключатель на каждого подозреваемого. Голый contenteditable там был безупречен. Каждый переключатель возвращал один слой, пока что-то не ломалось. Четыре раунда на живом телефоне осудили ровно четверых и оправдали всё остальное — замок body, маски, transform-слои, роль диалога — подозреваемых, которых иначе «чинили» бы днями.

Честным стенд держали два ограничения. Считается только физическое устройство: у симулятора нет ни настоящей клавиатуры, ни служебных панелей устройства над ней, а половина багов живёт именно там. И только продакшн-сборки: дев-бандлы настолько тяжелы, что WebKit убивает вкладку по памяти, и всё выглядит сломанным ещё до конца гидратации.

Виновник первый: href на ссылке внутри contenteditable

Для iOS <a href> интерактивен даже в редактируемом тексте: касание начинает жест «тап по ссылке», а тот жест старше жеста выделения — как только ползунок касается ссылки, выделение сбрасывается. Тот же анкор без href — просто текст. -webkit-touch-callout: none не спасает. И голый contenteditable без всякой библиотеки травится ровно так же — так что Lexical был невиновен.

Лекарство: href снимается с живого DOM редактора сразу после того, как Lexical нарисует или обновит узел ссылки. Потерь нет нигде: навигация в режиме редактирования и так выключена, адрес живёт в узле 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 на содержимом drawer-а

vaul держит логику «тяни-чтобы-закрыть» на содержимом drawer-а даже с handleOnly — и именно она убивала захват ползунков. Доказательством стала матрица на стенде: Lexical со ссылками в фейковом drawer-е той же геометрии — идеально; в настоящем vaul — мертво. Дальше по одному изменению: снять стили vaul (transform, will-change, touch-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

Вспомогательный хук бил принудительный focus() на каждое касание короче 300 мс — а быстрый захват ползунка и есть короткое касание. Отсюда легендарный симптом «подержи две секунды, тогда тянется»: долгое нажатие — не тап, focus() не летел, и перетаскивание выживало. Лекарство — одна строка: вызывать focus() только когда фокус ещё не в редакторе.

Виновник четвёртый: контейнер, едущий под пальцем

Первая архитектура подгоняла под клавиатуру весь drawer. Во время выделения iOS панорамирует экран — и drawer ехал за панорамированием, текст убегал из-под пальца, WebKit бросал перетаскивание. «Очевидное» лекарство — заморозить drawer, пока палец на экране, — провалилось иначе: текст сдвигался относительно пальца ровно на панорамирование, и выделение промахивалось на строку.

Правило, которое из этого родилось: двигать можно только то, чего не касаются. Drawer и текст стоят на всю высоту layout-окна — на iOS клавиатура layout не давит, она лишь накрывает его низ. Конкретно:

  • За клавиатурой гоняется только панель инструментов; нижний отступ текста едет через CSS-переменную (--keyboard-inset) — не движется ничто.
  • Петля кадров (requestAnimationFrame), а не события: iOS панорамирует во время выделения без надёжных событий visualViewport, а после закрытия клавиатуры offsetTop порой не сбрасывается вообще (регрессия iOS 26). Петле, которая просто смотрит каждый кадр, сигнал не нужен.
  • Стили пишутся в DOM мимо React — через состояние каждый кадр панорамирования перерисовывал бы всё дерево Lexical.
  • Прокручивается обёртка, а не contenteditable. Так делает и playground самого Lexical (editor-scroller): сфокусированный contenteditable, который сам себя листает, — худшая известная WebKit конфигурация: жест то листает, то начинает выделение, то отдаётся странице.
  • Через полсекунды после открытия с drawer-а снимаются transform, will-change и touch-action vaul: композитный слой сдвигает зону касания ползунков, а анимации открытия эти стили уже не нужны.

Панель над клавиатурой — и над всем, что iOS ставит над клавиатурой

«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 прокручивает страницу, чтобы отцентрировать поле, — странице некуда прокручиваться, и вёрстку разносит: drawer съезжает, панель исчезает. vaul лечит это в своём перехватчике фокуса, который мы только что заглушили, — так что лекарство воспроизведено руками: отменить родной фокус, подбросить поле трансформом вверх, сфокусировать самим, следующим кадром вернуть. 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 });

Замок прокрутки: обе причины были нашими

Прокрутка «запиралась» на дне: жесты вверх мертвы, пока не дёрнешь ещё ниже, будто отцепив. Стенд — на этот раз с живой клавиатурой — оправдал всю геометрию (распорку, панель-накладку, сам drawer) и осудил две вещи, обе наши собственные:

  • Отступ под клавиатуру жил внутри contenteditable. На дне видимую полосу над клавиатурой занимала пустая редактируемая зона — а касание по сфокусированному contenteditable iOS трактует как работу с текстом, не как прокрутку. Место под клавиатуру должно быть отдельным нередактируемым блоком после 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 решил, что это за жест. Край становится недосягаемым, и состояние «прилип» не наступает вообще.

Второе: momentum может довезти скроллер в край уже без пальца. Nudge на touchstart для этого опаздывает ровно на один свайп. А мгновенный nudge из обработчика scroll обрывал нативную пружинку — край ощущался ударом о стену. Поэтому nudge оседания отложен: когда поток событий 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 — убирает проблему вместе с самой пружиной: momentum встаёт на краю намертво, и край ощущается обрубленным.

Оправданные — не охотиться повторно

  • замок body position: fixed от vaul
  • CSS-маска затемнения на скроллере
  • transform-слой сам по себе (снимать правильно, но убийцей был не он)
  • роль диалога, aria- и data-атрибуты drawer-а
  • сам Lexical — голый contenteditable воспроизводит и отраву href, и замок прокрутки
  • глушение команды SELECTION_CHANGE_COMMAND Lexical — не изменило ничего
  • заслон на записях Selection.prototype — сам стал багом: тап перестал доносить каретку, и набор шёл в начало строки

Бонусный враг: нативное выделение против поля адреса

В правке ссылки конфликт встроен: нативное выделение iOS живёт только в сфокусированном редакторе, а полю адреса фокус нужен самому — вместе им не быть. Поэтому на время правки выделенный текст оборачивается в MarkNode Lexical: настоящую подсветку в DOM, которой безразлично, где фокус. Тем же механизмом playground самого Lexical подсвечивает цели комментариев.

Само выделение сохраняется непрерывно, на каждое его изменение — тот же паттерн lastSelection, что во FloatingLinkEditorPlugin самого Lexical, — потому что сохранять в момент нажатия кнопки поздно: тап по кнопке панели успевает свернуть выделение. При применении снятие метки воссоздаёт настоящее выделение ровно на её месте, а остальное делает TOGGLE_LINK_COMMAND.

Выводы

  • «Не работает выделение на iOS» — это не один баг. Причин было четыре, независимых, и каждая маскировала следующую — отладка по одной гипотезе на таком не сходится.
  • Бисекция бьёт дедукцию. Изолированный стенд с переключателем на подозреваемого превратил дни догадок в четыре раунда на устройстве — и оправдал тех, кого иначе «чинили» бы днями.
  • Правду говорит только физическое устройство: у симулятора нет ни настоящей клавиатуры, ни служебных панелей, а дев-бандлы гибнут по памяти ещё до появления самих багов.
  • Двигать только то, чего не касаются: статичный drawer, панель на петле кадров, нижний отступ через CSS-переменную.
  • Никогда не воевать с прокруткой iOS непрерывными записями или preventDefault на краях — оба становятся тем самым замком, от которого должны были спасать. Каждое выжившее лекарство — одна запись scrollTop в точно распознанный момент.
  • Полный протокол rubber-band: nudge на touchstart на спокойном краю, отложенный nudge оседания после momentum и клемп на touchstart посреди пружины — который iOS делает возможным, отдавая scrollTop за пределами.

Заметили ошибку?

Неверный факт, кривой перевод, что-то звучит неправдой в этой статье? Напишите мне — на своём языке.