Fire feil drepte tekstmarkering på iOS — og hver av dem skjulte den neste
En Lexical-editor i en vaul-drawer fungerte overalt unntatt på en ekte iPhone: markeringshåndtakene lot seg ikke dra, rullingen låste seg ved kantene, verktøylinjen dukket under tastaturet. Gjetting fikset ingenting, for det fantes ingen enkelt skyldig — det var fire uavhengige, og hver maskerte den neste. Det som fungerte var en biseksjonsrigg: samme skjelett med én bryter per mistenkt, testet på en fysisk enhet. Her er hele jakten — href som forgifter markeringen, vauls drag-håndterere, vår egen tap-focus, en beholder som flytter seg under fingeren — pluss obduksjonen av rulle-låsen og de tre engangsskrivingene av scrollTop som fikk rubber-band til å føles naturlig, inkludert å fange et sveip midt i spretten.
Den mobile teksteditoren i landee er Lexical i en vaul-drawer: trykk på en tekstblokk, en fullskjerms-drawer glir opp, du redigerer, en hake lukker den. På desktop og i simulatoren var alt bra. På en fysisk iPhone var det en katastrofe med flere uavhengige ansikter: markeringshåndtakene — de to blå nålene iOS gir for å strekke en markering — lot seg ikke dra i det hele tatt, eller først etter cirka to sekunders holding; dro man høyre håndtak mot venstre, kollapset hele markeringen; rullingen låste seg i bunnen til man dro enda lenger ned for å «hekte den løs»; og verktøylinjen dukket under tastaturet hver gang enheten la en ekstra stripe over det.
Dette er krøniken om hvordan det ble kurert — veien, ikke bare fiksene, for det er veien som kan gjenbrukes. Hovedlærdommen: når «markeringen virker ikke» på iOS, finnes det ingen enkelt skyldig å finne. De var fire, uavhengige av hverandre, og hver maskerte den neste — fikser du én, endres symptomene akkurat nok til at neste hypotese ser feil ut.
Metoden som mislyktes, og den som virket
De første timene gikk med til gjetting: kanskje drawerens transform-lag, kanskje CSS-masken på rulleren, kanskje Lexicals markeringskommandoer. Hver gjetning ga en plausibel lapp og null endring på enheten. To av lappene viste seg senere å være feil i seg selv: en lyddemper på Lexicals SELECTION_CHANGE_COMMAND, og en sperre over skrivemetodene på Selection.prototype som fikk tastingen til å lande i starten av linjen — et trykk leverte ikke lenger markøren.
Det som virket var biseksjon på en isolert rigg: en feilsøkingsside med samme skjelett som editoren — samme vaul-drawer, samme kolonne, samme ruller — og en bryter per mistenkt. Et nakent contenteditable var perfekt der. Hver bryter la tilbake ett lag til noe røk. Fire runder på en ekte telefon dømte nøyaktig fire ting og frikjente alt annet — body-låsen, maskene, transform-lagene, dialogrollen — mistenkte man ellers ville fortsatt å «fikse» i dagevis.
To regler holdt riggen ærlig. Bare en fysisk enhet teller: simulatoren har verken ekte tastatur eller enhetens ekstra striper over det, og halvparten av feilene bor akkurat der. Og bare produksjonsbygg teller: utviklingsbunter er så tunge at WebKit dreper fanen av minnemangel, og alt ser ødelagt ut før hydreringen engang er ferdig.
Skyldig én: href på en lenke inne i contenteditable
For iOS er <a href> interaktiv selv i redigerbar tekst: en berøring starter gesten «trykk på en lenke», og den gesten går foran markeringsgesten — i det øyeblikket et håndtak berører en lenke, kollapser markeringen. Samme anker uten href er bare tekst. -webkit-touch-callout: none redder ikke. Og et nakent contenteditable uten noe bibliotek forgiftes akkurat likt — så Lexical var uskyldig.
Kuren fjerner href fra editorens levende DOM rett etter at Lexical tegner eller oppdaterer en lenkenode. Ingenting går tapt noe sted: navigasjon er uansett slått av i redigeringsmodus, URL-en bor i Lexical-noden og forblir redigerbar, og den lagrede HTML-en beholder sin href — eksporten går via exportDOM, som bygger egne elementer og aldri ser det levende DOM-et.
// 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]);Skyldig to: vauls drag-håndterere på drawerens innhold
vaul holder dra-for-å-lukke-logikken festet til drawerens innhold selv med handleOnly — og nettopp den drepte håndtaksgrepet. Beviset var en matrise på riggen: Lexical med lenker i en falsk drawer med samme geometri — perfekt; i ekte vaul — dødt. Så, én endring om gangen: fjerne vauls stiler (transform, will-change, touch-action) — fortsatt dødt; fjerne dialogrollen og aria-attributtene — dødt; låse opp body — dødt; dempe hendelsene så de ikke når vauls håndterere — helt levende.
Demperen er stopPropagation, ikke preventDefault: nettleseren og Lexical hører alt, for lytterne deres sitter dypere i treet — bare vaul blir døv. Og den må dekke både pointer- og touch-hendelser: matrisen viste at pointer-stillhet alene ikke holder — håndtakene forblir døde så lenge vaul ennå hører 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>Skyldig tre: vår egen tap-to-focus
En hjelpe-hook tvang focus() ved hver berøring kortere enn 300 ms — og et raskt grep om et håndtak er nettopp en kort berøring. Det forklarer det legendariske symptomet «hold i to sekunder, så drar det»: et langt trykk er ingen tap, så focus() fyrte aldri og draingen overlevde. Kuren er én linje — kall focus() bare når editoren ikke allerede har fokus.
Skyldig fire: en beholder som flytter seg under fingeren
Den første arkitekturen endret størrelsen på hele draweren for å få plass over tastaturet. Under markering panorerer iOS skjermen — og draweren fulgte panoreringen, teksten flyktet under fingeren og WebKit slapp draingen. Den «åpenbare» fiksen — frys draweren mens en finger er nede — mislyktes på en annen måte: teksten forskjøv seg relativt til fingeren med nøyaktig panoreringen, og markeringen bommet med én linje.
Regelen som ble født av dette: man kan bare flytte det som ikke berøres. Draweren og teksten står i layout-viewportens fulle høyde — på iOS presser ikke tastaturet layouten, det dekker bare bunnen av den. Konkret:
- Bare verktøylinjen jager tastaturet; tekstens bunnmarg reiser via en CSS-variabel (
--keyboard-inset) — ingenting flytter seg. - En frame-løkke (
requestAnimationFrame), ikke hendelser: iOS panorerer under markering uten påliteligevisualViewport-hendelser, og etter at tastaturet lukkes tilbakestillesoffsetTopnoen ganger aldri (en iOS 26-regresjon). En løkke som bare ser hver frame trenger intet signal. - Stiler skrives rett i DOM, forbi React — via state ville hver panoreringsframe tegnet hele Lexical-treet på nytt.
- Omslaget ruller, ikke contenteditable. Lexicals egen playground gjør det samme (
editor-scroller): et fokusert contenteditable som ruller seg selv er den verste konfigurasjonen WebKit kjenner — gesten ruller iblant, starter iblant en markering, mater iblant siden. - Et halvt sekund etter åpning fjernes vauls
transform,will-changeogtouch-actionfra draweren: det kompositterte laget forskyver håndtakenes berøringssone, og åpningsanimasjonen trenger ikke lenger stilene.
Verktøylinjen over tastaturet — og over alt iOS legger over tastaturet
«100svh minus tastaturet» er løgn på en iPhone: adressefeltet tar sin egen bit, og enheten liker å legge ekstra striper over tastaturet. Sannheten kjenner visualViewport, og tallet verktøylinjen trenger er hvor mye av layout-viewportens bunn som er opptatt:
// 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`);Én finesse kostet en kveld. Når fokus flytter inn i et felt i selve verktøylinjen — skriftstørrelse, lenke-URL — bytter iOS tastaturtype, og visualViewport rapporterer overgangsverdier i noen titalls frames. Å bruke dem umiddelbart dykket verktøylinjen under tastaturet; å fryse løkken helt (forrige forsøk) gjorde den blind akkurat når høyden faktisk endret seg. Kompromisset: mens fokus er i verktøylinjen, må en ny verdi stå stille i ti frames før den brukes; i alle andre tilfeller gjelder den umiddelbart, så linjen rir tastaturets animasjon i stedet for å jage den.
Å fokusere de feltene krever sitt eget triks. Når en ekte input i en fiksert fullskjermsbeholder får fokus, ruller iOS siden for å sentrere feltet — siden har ingen steder å rulle, og layouten rives i stykker: draweren glir, linjen forsvinner. vaul kurerer dette i sin egen fokus-interceptor, som vi nettopp hadde dempet — så kuren gjenskapes for hånd: avbryt det native fokuset, teleporter feltet oppover med en transform, fokuser selv, sett tilbake neste frame. Safari tror feltet er «der oppe» og lar siden være i fred.
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 });Rulle-låsen: begge årsakene var våre egne
Rullingen «låste seg» i bunnen: gester oppover døde til man dro enda lenger ned, som for å hekte løs. Riggen — denne gangen med ekte tastatur — frikjente hele geometrien (avstandsstykket, overlegget, selve draweren) og dømte to ting, begge våre egne:
- Tastaturmargen bodde inne i contenteditable. I bunnen var den synlige stripen over tastaturet en tom redigerbar sone — og en berøring på et fokusert contenteditable tolker iOS som arbeid med teksten, ikke som rulling. Plassen til tastaturet må være en separat, ikke-redigerbar blokk etter contenteditable — ikke dens padding.
- Vår egen «sikring», bygget mot den første årsaken. Nær en kant vandrer scrollTop i brøkverdier, og en vakthund per frame «rettet» den med programmatiske skrivinger — og kontinuerlige programmatiske rulleskrivinger dreper menneskets gest. Med tastaturet åpent ble sikringen selv låsen.
Regelen: ingen preventDefault ved kantene og ingen scrollTop-vakter, aldri. Et enkelt overflow-y-auto-omslag med avstandsstykket utenfor contenteditable ruller perfekt — pluss overscroll-contain, så en kantgest ikke mater siden, som iOS gjør rullbar for det fokuserte feltets skyld.
<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>Tre engangsskrivinger: hele rubber-band-protokollen
Da låsene var borte, gjensto én familie av rariteter — og alle tre kurene viste seg å være enkeltskrivinger av scrollTop. Aldri løkker, aldri vakter. Først: en ruller som hviler på en eksakt kant (0 eller max) «setter seg fast» iblant — motsatt gest er død til man drar forbi kanten. Den kanoniske kuren, dokumentert i et tiår (iNoBounce med følge): ved touchstart, dytt scrollTop én piksel fra kanten — før WebKit bestemmer hvilken gest det er. Kanten blir unåelig, og fastlåst-tilstanden oppstår aldri.
Så: bevegelsesmengden kan bære rulleren inn i kanten når fingeren alt er borte. Touchstart-dyttet kommer nøyaktig ett sveip for sent til det. Og et umiddelbart dytt fra scroll-håndtereren kuttet den native fjæren — kanten føltes som å treffe en vegg. Så ro-ned-dyttet er utsatt: når scroll-hendelsene stilner i 140 ms uten finger nede og posisjonen er en eksakt kant, flytter én stille skriving den én piksel innover.
Tredje, det vanskeligste: et sveip under sprett-animasjonen ble rett og slett spist. Det er plattformoppførsel — mens rubber-band-animasjonen spiller, fester WebKit gesten til ingenting før den er ferdig. Man sveipte, ingenting flyttet seg, man sveipte igjen. Ingen funnet artikkel tilbyr et avbrudd; sporet ender ved «vent til den er ferdig».
Inngangen fantes: under spretten eksponerer iOS scrollTop utenfor grensene — negativt øverst, større enn max nederst. Det gjør øyeblikket strengt detekterbart: st < 0 || st > max er ekte overscroll og aldri rolig hvile ved en kant. Ved touchstart i den tilstanden avbryter én klampet skriving animasjonen — og den samme gesten griper rulleren. Med én sikring: bare første finger (touching === 1), for en animasjon spiller bare når ingen finger holdt rulleren, og en andre finger må ikke rykke teksten under den første.
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-hammeren (sett
overflow: hidden, klamp, tving en reflow, gjenopprett) — forkastet to ganger på enheten. Den første versjonen fyrte i tillegg ved rolige berøringer ved kanten (et<= 0der et< 0hørte hjemme) og brøt den normale rullingen på veien. overscroll-behavior: none— fjerner problemet sammen med selve fjæren: bevegelsesmengden dør brått ved kanten, og kanten føles avhugget.
Frikjent — ikke jakt på dem igjen
- vauls
position: fixed-body-lås - CSS-toningsmasken på rulleren
- transform-laget i seg selv (å fjerne det er riktig, men morderen var det ikke)
- drawerens dialogrolle, aria- og data-attributter
- Lexical selv — et nakent contenteditable gjenskaper både href-giften og rulle-låsen
- å dempe Lexicals
SELECTION_CHANGE_COMMAND— endret ingenting - sperren over
Selection.prototype-skrivinger — ble en egen feil: trykket leverte ikke lenger markøren, og tastingen gikk til starten av linjen
En bonusfiende: nativ markering mot URL-feltet
Å redigere en lenke bærer en innebygd konflikt: den native iOS-markeringen lever bare i en fokusert editor, og URL-feltet trenger fokuset selv — de kan ikke sameksistere. Så under redigeringen pakkes den markerte teksten inn i en Lexical-MarkNode: en ekte utheving i DOM som ikke bryr seg om hvor fokuset er. Med samme mekanisme uthever Lexicals egen playground kommentarmål.
Selve markeringen lagres kontinuerlig, ved hver endring — samme lastSelection-mønster som Lexicals FloatingLinkEditorPlugin — for å lagre ved knappetrykket er for sent: trykket på verktøylinjens knapp kollapser markeringen først. Ved bruk gjenskaper løsningen av merket den ekte markeringen nøyaktig på dens plass, og TOGGLE_LINK_COMMAND gjør resten.
Konklusjoner
- «Markeringen virker ikke på iOS» er ikke én feil. Det var fire uavhengige årsaker, hver maskerte den neste — feilsøking med én hypotese kan ikke konvergere der.
- Biseksjon slår deduksjon. En isolert rigg med én bryter per mistenkt gjorde dager med gjetting om til fire enhetsrunder — og frikjente de mistenkte som ellers ville blitt «fikset» i dagevis.
- Bare en fysisk enhet snakker sant: simulatoren har verken ekte tastatur eller ekstra striper, og dev-bunter dør av minne før feilene i det hele tatt dukker opp.
- Flytt bare det som ikke berøres: statisk drawer, verktøylinje på en frame-løkke, bunnmarg via CSS-variabel.
- Kjemp aldri mot iOS-rulling med kontinuerlige skrivinger eller preventDefault ved kantene — begge blir nettopp den låsen de skulle forhindre. Hver kur som overlevde er én enkelt scrollTop-skriving i et presist detektert øyeblikk.
- Hele rubber-band-protokollen: et dytt ved touchstart på en hvilende kant, et utsatt ro-ned-dytt etter bevegelsesmengde og en klamping ved touchstart midt i spretten — detekterbar fordi iOS eksponerer scrollTop utenfor grensene.
Fant du en feil?
Et feil faktum, en skjev oversettelse, noe som virker usant i denne artikkelen? Skriv til meg — på ditt eget språk.