Skip to main content
Блогқа оралу
iOSSafariWebKitLexicalcontenteditableDebugging

Төрт қате iOS-та мәтін таңдауды өлтірді — және әрқайсысы келесісін жасырды

Vaul drawer ішіндегі Lexical редакторы нағыз iPhone-нан басқа жердің бәрінде жұмыс істеді: таңдау тұтқалары сүйрелмеді, айналдыру шеттерде құлыпталды, құралдар тақтасы пернетақта астына батты. Болжау ештеңені емдемеді, себебі жалғыз кінәлі болған жоқ — тәуелсіз төртеу болды және әрқайсысы келесісін бүркемеледі. Жұмыс істегені — бисекция стенді: әр күдіктіге жеке қосқышы бар сол қаңқа, нақты құрылғыда тексерілді. Міне, толық аңшылық — таңдауды уландыратын href, vaul-дың drag өңдеушілері, өз tap-focus-ымыз, саусақ астында жылжитын контейнер — оған қоса скролл құлпының ашылуы және rubber-band-ты нағыз туған сезімге айналдырған үш бір реттік scrollTop жазбасы, серіппе ортасында свайпты қағып алуды қоса.

Жарияланды 2026 ж. 5 қыркүйек15 мин оқу

landee мобильді мәтін редакторы — vaul drawer ішіндегі Lexical: мәтін блогын түртесің, төменнен толық экранды тартпа шығады, өңдейсің, белгі оны жабады. Десктопта және симуляторда бәрі жақсы еді. Нағыз iPhone-да бұл бірнеше тәуелсіз жүзі бар апат болды: таңдау тұтқалары — iOS таңдауды созуға беретін екі көк түйреуіш — мүлдем сүйрелмеді, немесе екі секундтай ұстағаннан кейін ғана; оң тұтқаны солға тартсаң — бүкіл таңдау жоғалады; айналдыру түбінде құлыпталып, оны «ағыту» үшін тағы төмен тарту керек болды; ал құрылғы пернетақта үстіне қосымша жолақ қойған сайын құралдар тақтасы пернетақта астына батты.

Бұл — емделу шежіресі: тек емдер емес, жол, себебі өз жобаңа көшіруге тұрарлығы — жол. Басты сабақ: iOS-та «таңдау жұмыс істемейді» дегенде табуға тиіс жалғыз кінәлі жоқ. Олар төртеу еді, бір-бірінен тәуелсіз, және әрқайсысы келесісін бүркемеледі — біреуін жөндейсің, симптомдар келесі гипотезаны жалған көрсететіндей дәл өзгереді.

Стек: Next.js 16, React 19, Lexical 0.50, vaul негізіндегі drawer. Төмендегінің бәрі нағыз iPhone-да өндірістік құрастырулармен тексерілді; код үзінділері — өндіріске кеткен код, тек мәніне дейін қысқартылған.

Сәтсіз әдіс және жұмыс істеген әдіс

Алғашқы сағаттар болжауға кетті: мүмкін drawer-дің transform қабаты, мүмкін скроллердегі CSS маскасы, мүмкін Lexical-дың таңдау командалары. Әр болжам сенімді көрінетін жамау берді, ал құрылғыда — нөл өзгеріс. Сол жамаулардың екеуі кейін өздері қате болып шықты: Lexical-дың SELECTION_CHANGE_COMMAND-ына қойылған тыншытқыш және Selection.prototype жазу әдістерінің үстіндегі қалқан — сол себепті теру жолдың басына түсетін: түрту курсорды жеткізуді қойған.

Жұмыс істегені — оқшауланған стендтегі бисекция: редактормен бірдей қаңқасы бар жөндеу беті — сол vaul drawer, сол баған, сол скроллер — және әр күдіктіге жеке қосқыш батырма. Жалаңаш contenteditable ол жерде мінсіз еді. Әр қосқыш бір қабатты қайтарды, бірдеңе сынғанша. Нағыз телефондағы төрт раунд дәл төртеуін соттап, қалғанның бәрін ақтады — body құлпын, маскаларды, transform қабаттарын, диалог рөлін — әйтпесе күндер бойы «жөнделетін» күдіктілерді.

Стендті адал ұстаған екі шектеу. Тек физикалық құрылғы есептеледі: симуляторда нағыз пернетақта да, оның үстіндегі құрылғының қосымша панельдері де жоқ, ал қателердің жартысы дәл сонда тұрады. Және тек өндірістік құрастырулар есептеледі: даму бандлдары соншалық ауыр, WebKit бетті жад жетіспеуінен өлтіреді, және гидратация біткенше бәрі сынған көрінеді.

Бірінші кінәлі: contenteditable ішіндегі сілтемедегі href

iOS үшін <a href> өңделетін мәтіннің ішінде де интерактивті: жанасу «сілтемені түрту» қимылын бастайды, ал ол қимыл таңдау қимылынан басым — тұтқа сілтемеге тиген сәтте таңдау құлайды. Дәл сол анкор href-сіз — жай мәтін. -webkit-touch-callout: none құтқармайды. Ал ешқандай кітапханасыз жалаңаш contenteditable дәл солай уланады — демек Lexical кінәсіз еді.

Ем: Lexical сілтеме түйінін салған не жаңартқан бойда редактордың тірі DOM-ынан href алынады. Ешжерде шығын жоқ: өңдеу режимінде навигация онсыз да өшірулі, адрес 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]);

Екінші кінәлі: drawer мазмұнындағы vaul-дың drag өңдеушілері

vaul «жауып тастау үшін тарт» логикасын handleOnly болса да drawer-дің мазмұнында ұстайды — тұтқаларды ұстап алуды өлтірген дәл сол. Дәлел — стендтегі матрица: сол геометриядағы жалған drawer ішіндегі сілтемелері бар 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-ымыз

Көмекші hook 300 мс-тан қысқа әр жанасуға мәжбүрлі focus() соғатын — ал тұтқаны жылдам ұстап алу дәл сондай қысқа жанасу. Аңызға айналған «екі секунд ұста, сонда сүйреледі» симптомының түсіндірмесі осы: ұзақ басу — түрту емес, focus() ұшпайтын, сүйреу аман қалатын. Ем — бір жол: focus()-ты фокус әлі редакторда болмағанда ғана шақыру.

Төртінші кінәлі: саусақ астында жылжитын контейнер

Бірінші архитектура бүкіл drawer-ді пернетақтаға шақтап өзгертетін. Таңдау кезінде iOS экранды панорамалайды — drawer панорамаға ілесіп жүрді, мәтін саусақ астынан қашты, WebKit сүйреуді тастады. «Айқын» ем — саусақ экранда тұрғанда drawer-ді қатыру — басқаша құлады: мәтін саусаққа қатысты дәл панорама шамасына жылжып, таңдау бір жолға жаңылды.

Осыдан туған ереже: тек тиіп тұрмаған нәрсені ғана жылжытуға болады. Drawer мен мәтін layout көрінісінің толық биіктігінде тұрады — iOS-та пернетақта layout-ты қыспайды, тек оның төменін жабады. Нақтырақ:

  • Пернетақтаның соңынан тек құралдар тақтасы қуады; мәтіннің төменгі шегінісі CSS айнымалысы (--keyboard-inset) арқылы жүреді — ештеңе қозғалмайды.
  • Оқиғалар емес, кадр циклі (requestAnimationFrame): таңдау кезінде iOS сенімді visualViewport оқиғаларынсыз панорамалайды, ал пернетақта жабылған соң offsetTop кейде мүлдем қалпына келмейді (iOS 26 регрессиясы). Әр кадрда жай қарап тұратын циклге сигнал керек емес.
  • Стильдер React-ті айналып тікелей DOM-ға жазылады — күй арқылы жүрсе, әр панорама кадры бүкіл Lexical ағашын қайта салар еді.
  • Айналатын — орауыш, contenteditable емес. Lexical-дың өз playground-ы да солай (editor-scroller): өзін-өзі айналдыратын фокустағы contenteditable — WebKit білетін ең жаман конфигурация: қимыл бірде айналдырады, бірде таңдау бастайды, бірде бетке беріледі.
  • Ашылғаннан жарты секунд өткенде drawer-ден vaul-дың transform, will-change және touch-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 өрісті ортаға қою үшін бетті айналдырады — беттің айналатын жері жоқ, верстка быт-шыт болады: drawer жылжиды, тақта жоғалады. 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 });

Скролл құлпы: екі себеп те біздікі еді

Айналдыру түбінде «құлыпталды»: жоғары қимылдар өлі, ағытқандай тағы төмен тартқанша. Стенд — бұл жолы тірі пернетақтамен — бүкіл геометрияны ақтады (тіреуішті, қабаттасқан тақтаны, drawer-дің өзін) және екі нәрсені соттады, екеуі де өзіміздікі:

  • Пернетақта шегінісі contenteditable ішінде тұрды. Түбінде пернетақта үстіндегі көрінетін жолақ бос өңделетін аймақ еді — ал фокустағы contenteditable-ға жанасуды iOS мәтінмен жұмыс деп санайды, айналдыру деп емес. Пернетақтаға орын contenteditable-дан кейінгі бөлек, өңделмейтін блок болуы тиіс — оның padding-і емес.
  • Бірінші себепке қарсы құрылған өз «сақтандырғышымыз». Жиек маңында scrollTop бөлшек мәндермен жүреді, ал әр кадрлық күзетші оны бағдарламалық жазбалармен «түзеп» тұрды — ал үздіксіз бағдарламалық айналдыру жазбалары адам қимылын өлтіреді. Пернетақта ашық тұрғанда сақтандырғыштың өзі құлыпқа айналды.

Ереже: жиектерде ешқандай preventDefault және ешқандай scrollTop күзетшілері, ешқашан. Тіреуіші contenteditable сыртындағы қарапайым overflow-y-auto орауышы мінсіз айналады — оған қоса 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 қабатының өзі (алу дұрыс, бірақ өлтіруші ол емес)
  • drawer-дің диалог рөлі, aria және data атрибуттары
  • Lexical-дың өзі — жалаңаш contenteditable href уын да, скролл құлпын да қайталайды
  • Lexical-дың SELECTION_CHANGE_COMMAND-ын тыншыту — ештеңе өзгертпеді
  • Selection.prototype жазбаларының үстіндегі қалқан — өзі қатеге айналды: түрту курсорды жеткізуді қойды, теру жолдың басына кетті

Бонус жау: төл таңдау URL өрісіне қарсы

Сілтемені өңдеуде қайшылық іштей бар: iOS-тың төл таңдауы тек фокустағы редакторда өмір сүреді, ал URL өрісіне фокус өзіне керек — бірге болу мүмкін емес. Сондықтан өңдеу уақытына таңдалған мәтін Lexical-дың MarkNode-ына оралады: фокус қайда екеніне бәрібір, DOM-дағы нағыз белгілеу. Дәл сол механизммен Lexical-дың өз playground-ы пікір нысандарын белгілейді.

Таңдаудың өзі үздіксіз сақталады, әр өзгерісінде — Lexical-дың FloatingLinkEditorPlugin-індегі сол lastSelection үлгісі — өйткені батырма басылған сәтте сақтау кеш: тақта батырмасын түрту таңдауды алдымен құлатып үлгереді. Қолдану кезінде белгіні алу нағыз таңдауды дәл орнында қайта жасайды, қалғанын TOGGLE_LINK_COMMAND істейді.

Қорытындылар

  • «iOS-та таңдау жұмыс істемейді» — бір қате емес. Төрт тәуелсіз себеп болды, әрқайсысы келесісін бүркемеледі — бір гипотезалық жөндеу мұнда жинақталмайды.
  • Бисекция дедукцияны жеңеді. Әр күдіктіге жеке қосқышы бар оқшауланған стенд болжау күндерін құрылғыдағы төрт раундқа айналдырды — және әйтпесе күндер бойы «жөнделетін» күдіктілерді ақтады.
  • Шындықты тек физикалық құрылғы айтады: симуляторда нағыз пернетақта да, қосымша панельдер де жоқ, ал dev-бандлдар қателер шыққанша жадтан өледі.
  • Тек тиіп тұрмағанды жылжыт: статикалық drawer, кадр цикліндегі тақта, CSS айнымалысы арқылы төменгі шегініс.
  • iOS айналдыруымен ешқашан үздіксіз жазбалармен не жиектердегі preventDefault-пен соғыспа — екеуі де өздері болдырмауға тиіс құлыптың дәл өзіне айналады. Аман қалған әр ем — дәл анықталған сәттегі жалғыз scrollTop жазбасы.
  • Толық rubber-band хаттамасы: тыныш жиектегі touchstart жылжытуы, инерциядан кейінгі кейінге қалдырылған тыныштану жылжытуы және серпіліс ортасындағы touchstart шектеуі — iOS scrollTop-ты шектен тыс беретіндіктен анықталады.

Қате байқадыңыз ба?

Осы мақалада қате факт, олқы аударма немесе жалған естілетін нәрсе бар ма? Маған жазыңыз — өз тіліңізде.