Четыре бага убивали выделение текста на iOS — и каждый прятал следующий
Редактор Lexical в drawer-е vaul работал везде, кроме живого айфона: ползунки выделения не тянулись, прокрутка запиралась на краях, панель инструментов ныряла под клавиатуру. Догадки не лечили ничего, потому что единственного виновника не существовало — их было четверо, независимых, и каждый маскировал следующего. Сработал стенд бисекции: тот же каркас с переключателем на каждого подозреваемого, проверенный на физическом устройстве. Это полная охота — href, отравляющий выделение, обработчики перетаскивания vaul, наш собственный tap-focus, контейнер, едущий под пальцем, — плюс вскрытие замка прокрутки и три одноразовые записи scrollTop, сделавшие rubber-band родным, включая перехват свайпа посреди пружины.
Мобильный текстовый редактор в landee — это Lexical внутри drawer-а vaul: тап по текстовому блоку, снизу выезжает полноэкранный ящик, редактируешь, галочка закрывает. На десктопе и в симуляторе всё было хорошо. На физическом айфоне это была катастрофа с несколькими независимыми лицами: ползунки выделения — те два синих кружочка, которыми iOS даёт растянуть выделенное, — не тянулись вообще или только после удержания секунды две; потянешь правый ползунок влево — всё выделение пропадает; прокрутка запиралась на дне, пока не дёрнешь ещё ниже, будто «отцепив» её; а панель инструментов ныряла под клавиатуру всякий раз, когда устройство ставило над ней свою служебную полосу.
Это хроника того, как оно вылечилось — именно маршрут, а не только лекарства, потому что переносить на свои проекты стоит маршрут. Главный урок: когда «не работает выделение» на iOS, не существует одного виновника, которого нужно найти. Их было четверо, независимых друг от друга, и каждый маскировал следующего — починишь одного, симптомы изменятся ровно настолько, чтобы следующая гипотеза выглядела ложной.
Метод, который провалился, и метод, который сработал
Первые часы ушли на догадки: может, 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 не видит.
// 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.
{/* 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-actionvaul: композитный слой сдвигает зону касания ползунков, а анимации открытия эти стили уже не нужны.
Панель над клавиатурой — и над всем, что iOS ставит над клавиатурой
«100svh минус клавиатура» на айфоне — ложь: свой кусок забирает адресная строка, а над клавиатурой устройство любит ставить служебные панели. Правду знает visualViewport, и панели нужно одно число — сколько низа layout-окна занято:
// 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 верит, что поле «наверху», и страницу не трогает.
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 делает прокручиваемой ради сфокусированного поля.
<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), потому что анимация играет лишь когда скроллер никто не держал, и второй палец не должен выдёргивать текст из-под первого.
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_COMMANDLexical — не изменило ничего - заслон на записях
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 за пределами.
Заметили ошибку?
Неверный факт, кривой перевод, что-то звучит неправдой в этой статье? Напишите мне — на своём языке.