Fire fejl dræbte tekstmarkering på iOS — og hver af dem skjulte den næste
En Lexical-editor i en vaul-drawer virkede overalt undtagen på en rigtig iPhone: markeringshåndtagene kunne ikke trækkes, scrollingen låste ved kanterne, værktøjslinjen dykkede under tastaturet. Gætteri fiksede ingenting, for der var ingen enkelt skyldig — der var fire uafhængige, og hver maskerede den næste. Det, der virkede, var en bisektionsrig: samme skelet med én kontakt per mistænkt, testet på en fysisk enhed. Her er hele jagten — href der forgifter markeringen, vauls drag-handlers, vores egen tap-focus, en container der flytter sig under fingeren — plus obduktionen af scroll-låsen og de tre engangsskrivninger af scrollTop, der fik rubber-band til at føles naturligt, inklusive at fange et swipe midt i hoppet.
Den mobile teksteditor i landee er Lexical i en vaul-drawer: tryk på en tekstblok, en fuldskærms-drawer glider op, du redigerer, et flueben lukker den. På desktop og i simulatoren var alt fint. På en fysisk iPhone var det en katastrofe med flere uafhængige ansigter: markeringshåndtagene — de to blå nåle, iOS giver til at strække en markering — kunne slet ikke trækkes, eller først efter cirka to sekunders hold; trak man det højre håndtag mod venstre, kollapsede hele markeringen; scrollingen låste i bunden, indtil man trak endnu længere ned for at «hægte den af»; og værktøjslinjen dykkede under tastaturet, hver gang enheden lagde en ekstra bjælke over det.
Dette er krøniken om, hvordan det blev kureret — vejen, ikke kun rettelserne, for det er vejen, der kan genbruges. Hovedlektionen: når «markeringen virker ikke» på iOS, findes der ikke én skyldig at finde. De var fire, uafhængige af hinanden, og hver maskerede den næste — retter man én, ændres symptomerne lige nok til, at den næste hypotese ser forkert ud.
Metoden der fejlede, og den der virkede
De første timer gik med gætterier: måske drawerens transform-lag, måske CSS-masken på scrolleren, måske Lexicals markeringskommandoer. Hvert gæt gav en plausibel lap og nul ændring på enheden. To af lapperne viste sig senere selv at være fejl: en lyddæmper på Lexicals SELECTION_CHANGE_COMMAND, og en spærre over skrivemetoderne på Selection.prototype, der fik tastningen til at lande i linjens begyndelse — et tryk leverede ikke længere markøren.
Det, der virkede, var bisektion på en isoleret rig: en fejlsøgningsside med samme skelet som editoren — samme vaul-drawer, samme kolonne, samme scroller — og en kontakt per mistænkt. Et nøgent contenteditable var perfekt dér. Hver kontakt lagde ét lag tilbage, indtil noget gik i stykker. Fire runder på en rigtig telefon dømte præcis fire ting og frikendte alt andet — body-låsen, maskerne, transform-lagene, dialogrollen — mistænkte, man ellers ville have «repareret» i dagevis.
To regler holdt riggen ærlig. Kun en fysisk enhed tæller: simulatoren har hverken rigtigt tastatur eller enhedens ekstra bjælker over det, og halvdelen af fejlene bor præcis dér. Og kun produktionsbuilds tæller: udviklingsbundterne er så tunge, at WebKit dræber fanen af hukommelsesmangel, og alt ser ødelagt ud, før hydreringen overhovedet er færdig.
Skyldig ét: href på et link inde i contenteditable
For iOS er <a href> interaktiv selv i redigerbar tekst: en berøring starter gestussen «tryk på et link», og den gestus går forud for markeringsgestussen — i det øjeblik et håndtag rører et link, kollapser markeringen. Samme anker uden href er bare tekst. -webkit-touch-callout: none redder ikke. Og et nøgent contenteditable uden noget bibliotek forgiftes præcis ligesådan — så Lexical var uskyldig.
Kuren fjerner href fra editorens levende DOM, lige efter at Lexical tegner eller opdaterer en linknode. Intet går tabt nogen steder: navigation er alligevel slået fra i redigeringstilstand, URL'en bor i Lexical-noden og forbliver redigerbar, og den gemte HTML beholder sit href — eksporten går via exportDOM, som bygger sine egne elementer og aldrig ser det levende 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]);Skyldig to: vauls drag-handlers på drawerens indhold
vaul holder sin træk-for-at-lukke-logik fastgjort til drawerens indhold selv med handleOnly — og netop den dræbte håndtagsgrebet. Beviset var en matrix på riggen: Lexical med links i en falsk drawer med samme geometri — perfekt; i den rigtige vaul — dødt. Så, én ændring ad gangen: fjern vauls stilarter (transform, will-change, touch-action) — stadig dødt; fjern dialogrollen og aria-attributterne — dødt; lås body op — dødt; dæmp begivenhederne, så de ikke når vauls handlers — helt levende.
Dæmperen er stopPropagation, ikke preventDefault: browseren og Lexical hører alt, for deres lyttere sidder dybere i træet — kun vaul bliver døv. Og den skal dække både pointer- og touch-begivenheder: matrixen viste, at pointer-stilhed alene ikke er nok — håndtagene forbliver døde, så længe vaul stadig 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: vores egen tap-to-focus
En hjælpe-hook tvang focus() ved hver berøring kortere end 300 ms — og et hurtigt greb om et håndtag er netop en kort berøring. Det forklarer det legendariske symptom «hold i to sekunder, så trækker det»: et langt tryk er ikke et tap, så focus() affyrede aldrig, og trækket overlevede. Kuren er én linje — kald kun focus(), når editoren ikke allerede har fokus.
Skyldig fire: en beholder der flytter sig under fingeren
Den første arkitektur ændrede størrelsen på hele draweren for at være over tastaturet. Under markering panorerer iOS skærmen — og draweren fulgte panoreringen, teksten flygtede under fingeren, og WebKit slap trækket. Den «oplagte» rettelse — frys draweren, mens en finger er nede — fejlede på en anden måde: teksten forskød sig relativt til fingeren med præcis panoreringen, og markeringen ramte en linje forkert.
Reglen, der blev født af dette: man må kun flytte det, der ikke berøres. Draweren og teksten står i layout-viewportens fulde højde — på iOS presser tastaturet ikke layoutet, det dækker kun dets bund. Konkret:
- Kun værktøjslinjen jagter tastaturet; tekstens bundmargen rejser via en CSS-variabel (
--keyboard-inset) — intet flytter sig. - En frame-løkke (
requestAnimationFrame), ikke begivenheder: iOS panorerer under markering uden pålideligevisualViewport-begivenheder, og efter tastaturet lukkes, nulstillesoffsetTopnogle gange aldrig (en iOS 26-regression). En løkke, der bare kigger hver frame, behøver intet signal. - Stilarter skrives direkte i DOM, uden om React — via state ville hver panoreringsframe gentegne hele Lexical-træet.
- Indpakningen scroller, ikke contenteditable. Lexicals egen playground gør det samme (
editor-scroller): et fokuseret contenteditable, der scroller sig selv, er den værste konfiguration WebKit kender — gestussen scroller nogle gange, starter nogle gange en markering, fodrer nogle gange siden. - Et halvt sekund efter åbning fjernes vauls
transform,will-changeogtouch-actionfra draweren: det kompositerede lag forskyder håndtagenes berøringszone, og åbningsanimationen behøver ikke længere stilarterne.
Værktøjslinjen over tastaturet — og over alt, hvad iOS lægger over tastaturet
«100svh minus tastaturet» er løgn på en iPhone: adresselinjen tager sin egen bid, og enheden kan lide at lægge ekstra bjælker over tastaturet. Sandheden kender visualViewport, og tallet, værktøjslinjen har brug for, er hvor meget af layout-viewportens bund der er optaget:
// 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 kostede en aften. Når fokus flytter ind i et felt i selve værktøjslinjen — skriftstørrelse, link-URL — skifter iOS tastaturtype, og visualViewport rapporterer overgangsværdier i nogle dusin frames. At anvende dem straks dykkede værktøjslinjen under tastaturet; at fryse løkken helt (forrige forsøg) gjorde den blind, præcis når højden faktisk ændrede sig. Kompromiset: mens fokus er i værktøjslinjen, skal en ny værdi stå stille i ti frames, før den anvendes; i alle andre tilfælde gælder den straks, så linjen rider på tastaturets animation i stedet for at jagte den.
At fokusere de felter kræver sit eget trick. Når et rigtigt input i en fikseret fuldskærmsbeholder får fokus, scroller iOS siden for at centrere feltet — siden har ingen steder at scrolle, og layoutet flås i stykker: draweren glider, linjen forsvinder. vaul kurerer dette i sin egen fokus-interceptor, som vi lige havde dæmpet — så kuren genskabes i hånden: annullér det native fokus, teleportér feltet opad med en transform, fokusér selv, sæt tilbage næste frame. Safari tror, feltet er «deroppe», og lader 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 });Scroll-låsen: begge årsager var vores egne
Scrollingen «låste» i bunden: gestusser opad døde, indtil man trak endnu længere ned, som for at hægte af. Riggen — denne gang med rigtigt tastatur — frikendte hele geometrien (afstandsstykket, overlægget, selve draweren) og dømte to ting, begge vores egne:
- Tastaturmargenen boede inde i contenteditable. I bunden var den synlige stribe over tastaturet en tom redigerbar zone — og en berøring på et fokuseret contenteditable tolker iOS som arbejde med teksten, ikke som scrolling. Pladsen til tastaturet skal være en separat, ikke-redigerbar blok efter contenteditable — ikke dens padding.
- Vores egen «sikring», bygget mod den første årsag. Nær en kant vandrer scrollTop i brøkværdier, og en vagthund per frame «rettede» den med programmatiske skrivninger — og kontinuerlige programmatiske scroll-skrivninger dræber menneskets gestus. Med tastaturet åbent blev sikringen selv låsen.
Reglen: intet preventDefault ved kanterne og ingen scrollTop-vagter, aldrig. En simpel overflow-y-auto-indpakning med afstandsstykket uden for contenteditable scroller perfekt — plus overscroll-contain, så en kantgestus ikke fodrer siden, som iOS gør scrollbar for det fokuserede felts 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 engangsskrivninger: hele rubber-band-protokollen
Da låsene var væk, var der én familie af særheder tilbage — og alle tre kure viste sig at være enkeltskrivninger af scrollTop. Aldrig løkker, aldrig vagter. Først: en scroller, der hviler på en eksakt kant (0 eller max), «klæber» nogle gange — den modsatte gestus er død, indtil man trækker forbi kanten. Den kanoniske kur, dokumenteret i et årti (iNoBounce og venner): ved touchstart, skub scrollTop én pixel fra kanten — før WebKit beslutter, hvilken gestus det er. Kanten bliver uopnåelig, og klæbe-tilstanden opstår aldrig.
Dernæst: momentum kan bære scrolleren ind i kanten, når fingeren allerede er væk. Touchstart-skubbet kommer præcis ét swipe for sent til det. Og et øjeblikkeligt skub fra scroll-handleren klippede den native fjeder — kanten føltes som at ramme en væg. Så berolige-skubbet er udskudt: når scroll-begivenhederne tier i 140 ms uden finger nede, og positionen er en eksakt kant, flytter én stille skrivning den én pixel indad.
Tredje, det sværeste: et swipe under hop-animationen blev simpelthen ædt. Det er platformsadfærd — mens rubber-band-animationen spiller, fæstner WebKit gestussen til ingenting, før den er færdig. Man swipede, intet flyttede sig, man swipede igen. Ingen fundet artikel tilbyder en afbrydelse; sporet ender ved «vent til den er færdig».
Indgangen fandtes: under hoppet eksponerer iOS scrollTop uden for grænserne — negativt øverst, større end max nederst. Det gør øjeblikket strengt detekterbart: st < 0 || st > max er ægte overscroll og aldrig rolig hvile ved en kant. Ved touchstart i den tilstand afbryder én klemt skrivning animationen — og selvsamme gestus griber scrolleren. Med én sikring: kun den første finger (touching === 1), for en animation spiller kun, når ingen finger holdt scrolleren, og en anden finger må ikke rive teksten væk 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 (sæt
overflow: hidden, klem, tving et reflow, gendan) — forkastet to gange på enheden. Dens første version affyrede desuden ved rolige berøringer ved kanten (et<= 0, hvor et< 0hørte hjemme) og ødelagde undervejs den normale scrolling. overscroll-behavior: none— fjerner problemet sammen med selve fjederen: momentum dør brat ved kanten, og kanten føles hugget af.
Frikendt — jag dem ikke igen
- vauls
position: fixed-body-lås - CSS-toningsmasken på scrolleren
- transform-laget i sig selv (at fjerne det er rigtigt, men morderen var det ikke)
- drawerens dialogrolle, aria- og data-attributter
- Lexical selv — et nøgent contenteditable genskaber både href-giften og scroll-låsen
- at dæmpe Lexicals
SELECTION_CHANGE_COMMAND— ændrede intet - spærren over
Selection.prototype-skrivninger — blev selv en fejl: trykket leverede ikke længere markøren, og tastningen gik til linjens begyndelse
En bonusfjende: nativ markering mod URL-feltet
At redigere et link bærer en indbygget konflikt: den native iOS-markering lever kun i en fokuseret editor, og URL-feltet har brug for fokus selv — de kan ikke sameksistere. Så under redigeringen pakkes den markerede tekst ind i en Lexical-MarkNode: en ægte fremhævning i DOM, der er ligeglad med, hvor fokus er. Med samme mekanisme fremhæver Lexicals egen playground kommentarmål.
Selve markeringen gemmes kontinuerligt, ved hver ændring — samme lastSelection-mønster som Lexicals FloatingLinkEditorPlugin — for at gemme ved knaptrykket er for sent: trykket på værktøjslinjens knap kollapser markeringen først. Ved anvendelse genskaber løsningen af mærket den ægte markering præcis på dens plads, og TOGGLE_LINK_COMMAND gør resten.
Konklusioner
- «Markeringen virker ikke på iOS» er ikke én fejl. Der var fire uafhængige årsager, hver maskerede den næste — fejlsøgning med én hypotese kan ikke konvergere dér.
- Bisektion slår deduktion. En isoleret rig med én kontakt per mistænkt forvandlede dage af gætterier til fire enhedsrunder — og frikendte de mistænkte, der ellers ville være blevet «repareret» i dagevis.
- Kun en fysisk enhed taler sandt: simulatoren har hverken rigtigt tastatur eller ekstra bjælker, og dev-bundter dør af hukommelse, før fejlene overhovedet dukker op.
- Flyt kun det, der ikke berøres: statisk drawer, værktøjslinje på en frame-løkke, bundmargen via CSS-variabel.
- Kæmp aldrig mod iOS-scrolling med kontinuerlige skrivninger eller preventDefault ved kanterne — begge bliver netop den lås, de skulle forhindre. Hver kur, der overlevede, er én enkelt scrollTop-skrivning i et præcist detekteret øjeblik.
- Hele rubber-band-protokollen: et skub ved touchstart på en hvilende kant, et udskudt berolige-skub efter momentum og en klemning ved touchstart midt i hoppet — detekterbar, fordi iOS eksponerer scrollTop uden for grænserne.
Fandt du en fejl?
Et forkert faktum, en skæv oversættelse, noget der virker usandt i denne artikel? Skriv til mig — på dit eget sprog.