Чотири вади вбивали виділення тексту на iOS — і кожна ховала наступну
Редактор Lexical у дравері vaul працював скрізь, крім живого айфона: повзунки виділення не тягнулись, прокрутка замикалась на краях, панель інструментів пірнала під клавіатуру. Здогадки не лікували нічого, бо єдиного винного не існувало — їх було четверо, незалежних, і кожен маскував наступного. Спрацював стенд бісекції: той самий каркас із перемикачем на кожного підозрюваного, перевірений на фізичному пристрої. Це повне полювання — href, що труїть виділення, обробники перетягування vaul, наш власний tap-focus, контейнер, що їде під пальцем, — плюс розтин замка прокрутки і три одноразові записи scrollTop, які зробили rubber-band рідним, включно з перехопленням свайпу посеред пружини.
Мобільний текстовий редактор у landee — це Lexical усередині дравера vaul: тап по текстовому блоку, знизу виїжджає повноекранна шухляда, редагуєш, галочка закриває. На десктопі й у симуляторі все було гаразд. На фізичному айфоні це була катастрофа з кількома незалежними обличчями: повзунки виділення — ті двоє синіх кружечків, якими iOS дає розтягнути виділене, — не тягнулись узагалі або лише після утримання секунди зо дві; потягнеш правий повзунок ліворуч — усе виділення пропадає; прокрутка замикалась на дні, поки не смикнеш ще нижче, ніби «відчепивши» її; а панель інструментів пірнала під клавіатуру щоразу, коли пристрій ставив над нею свою службову смугу.
Це хроніка того, як воно вилікувалось — саме маршрут, а не лише ліки, бо переносити на свої проєкти варто маршрут. Головний урок: коли «не працює виділення» на iOS, не існує одного винного, якого треба знайти. Їх було четверо, незалежних одне від одного, і кожен маскував наступного — полагодиш одного, симптоми зміняться рівно настільки, щоб наступна гіпотеза виглядала хибною.
Метод, який провалився, і метод, який спрацював
Перші години пішли на здогадки: може, transform-шар дравера, може, CSS-маска на скролері, може, команди виділення Lexical. Кожна здогадка давала правдоподібну латку і нуль змін на пристрої. Дві з тих латок пізніше виявились вадами самі: глушник команди SELECTION_CHANGE_COMMAND у Lexical і заслін на методах запису Selection.prototype, через який набір ішов у початок рядка — тап переставав доносити каретку.
Спрацювала бісекція на ізольованому стенді: налагоджувальна сторінка з тим самим каркасом, що в редакторі, — той самий дравер 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 на вмісті дравера
vaul тримає логіку «тягни-щоб-закрити» на вмісті дравера навіть із handleOnly — і саме вона вбивала захоплення повзунків. Доказом стала матриця на стенді: Lexical із посиланнями у фейковому дравері тієї самої геометрії — ідеально; у справжньому 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() лише коли фокус ще не в редакторі.
Винний четвертий: контейнер, що їде під пальцем
Перша архітектура підганяла під клавіатуру весь дравер. Під час виділення iOS панорамує екран — і дравер їхав за панорамуванням, текст тікав з-під пальця, WebKit кидав перетягування. «Очевидні» ліки — заморозити дравер, поки палець на екрані, — провалились інакше: текст зсувався відносно пальця рівно на панорамування, і виділення промахувалось на рядок.
Правило, що з цього народилось: рухати можна лише те, чого не торкаються. Дравер і текст стоять на всю висоту layout-вікна — на iOS клавіатура layout не тисне, вона лише накриває його низ. Конкретно:
- За клавіатурою ганяється лише панель інструментів; нижній відступ тексту їде через CSS-змінну (
--keyboard-inset) — не рухається ніщо. - Петля кадрів (
requestAnimationFrame), а не події: iOS панорамує під час виділення без надійних подійvisualViewport, а після закриття клавіатуриoffsetTopподекуди не скидається взагалі (регресія iOS 26). Петлі, що просто дивиться щокадру, сигнал не потрібен. - Стилі пишуться в DOM повз React — через стан кожен кадр панорамування перемальовував би все дерево Lexical.
- Прокручується обгортка, а не contenteditable. Так робить і playground самого Lexical (
editor-scroller): зафокушений contenteditable, який сам себе гортає, — найгірша відома WebKit конфігурація: жест то гортає, то починає виділення, то віддається сторінці. - Через пів секунди після відкриття з дравера знімаються
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 прокручує сторінку, щоб відцентрувати поле, — сторінці нікуди прокручуватись, і верстку розносить: дравер зʼїжджає, панель зникає. 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 });Замок прокрутки: обидві причини були нашими
Прокрутка «замикалась» на дні: жести вгору мертві, поки не смикнеш ще нижче, ніби відчепивши. Стенд — цього разу з живою клавіатурою — виправдав усю геометрію (розпірку, панель-накладку, сам дравер) і засудив дві речі, обидві наші власні:
- Відступ під клавіатуру жив усередині 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-атрибути дравера
- сам Lexical — голий contenteditable відтворює і отруту href, і замок прокрутки
- глушіння команди
SELECTION_CHANGE_COMMANDLexical — не змінило нічого - заслін на записах
Selection.prototype— сам став вадою: тап перестав доносити каретку, і набір ішов у початок рядка
Бонусний ворог: нативне виділення проти поля адреси
У правці посилання конфлікт вбудований: нативне виділення iOS живе лише в зафокушеному редакторі, а полю адреси фокус потрібен самому — разом їм не бути. Тож на час правки виділений текст обгортається в MarkNode Lexical: справжню підсвітку в DOM, якій байдуже, де фокус. Тим самим механізмом playground самого Lexical підсвічує цілі коментарів.
Саме виділення зберігається безперервно, на кожну його зміну — той самий патерн lastSelection, що у FloatingLinkEditorPlugin самого Lexical, — бо зберігати в мить натискання кнопки запізно: тап по кнопці панелі встигає згорнути виділення. При застосуванні зняття марки відтворює справжнє виділення рівно на її місці, а решту робить TOGGLE_LINK_COMMAND.
Висновки
- «Не працює виділення на iOS» — це не одна вада. Причин було чотири, незалежних, і кожна маскувала наступну — налагодження за однією гіпотезою на такому не сходиться.
- Бісекція бʼє дедукцію. Ізольований стенд із перемикачем на підозрюваного перетворив дні здогадок на чотири раунди на пристрої — і виправдав тих, кого інакше «лагодили» б днями.
- Правду каже лише фізичний пристрій: у симулятора немає ні справжньої клавіатури, ні службових панелей, а дев-бандли гинуть по памʼяті ще до появи самих вад.
- Рухати лише те, чого не торкаються: статичний дравер, панель на петлі кадрів, нижній відступ через CSS-змінну.
- Ніколи не воювати з прокруткою iOS безперервними записами чи preventDefault на краях — обидва стають тим самим замком, від якого мали рятувати. Кожні ліки, що вижили, — один запис scrollTop у точно розпізнаний момент.
- Повний протокол rubber-band: nudge на touchstart на спокійному краю, відкладений nudge осідання після momentum і клемп на touchstart посеред пружини — який iOS робить можливим, віддаючи scrollTop за межами.
Помітили помилку?
Невірний факт, кривий переклад, щось звучить неправдиво в цій статті? Напишіть мені — своєю мовою.