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

Чотири вади вбивали виділення тексту на iOS — і кожна ховала наступну

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

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

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

Це хроніка того, як воно вилікувалось — саме маршрут, а не лише ліки, бо переносити на свої проєкти варто маршрут. Головний урок: коли «не працює виділення» на iOS, не існує одного винного, якого треба знайти. Їх було четверо, незалежних одне від одного, і кожен маскував наступного — полагодиш одного, симптоми зміняться рівно настільки, щоб наступна гіпотеза виглядала хибною.

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

Метод, який провалився, і метод, який спрацював

Перші години пішли на здогадки: може, 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 не бачить.

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 на вмісті дравера

vaul тримає логіку «тягни-щоб-закрити» на вмісті дравера навіть із handleOnly — і саме вона вбивала захоплення повзунків. Доказом стала матриця на стенді: Lexical із посиланнями у фейковому дравері тієї самої геометрії — ідеально; у справжньому 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() лише коли фокус ще не в редакторі.

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

Перша архітектура підганяла під клавіатуру весь дравер. Під час виділення 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-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 прокручує сторінку, щоб відцентрувати поле, — сторінці нікуди прокручуватись, і верстку розносить: дравер зʼїжджає, панель зникає. 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 });

Замок прокрутки: обидві причини були нашими

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

  • Відступ під клавіатуру жив усередині 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-атрибути дравера
  • сам Lexical — голий contenteditable відтворює і отруту href, і замок прокрутки
  • глушіння команди SELECTION_CHANGE_COMMAND Lexical — не змінило нічого
  • заслін на записах 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 за межами.

Помітили помилку?

Невірний факт, кривий переклад, щось звучить неправдиво в цій статті? Напишіть мені — своєю мовою.