Skip to main content
Tilbage til bloggen
iOSSafariWebKitLexicalcontenteditableDebugging

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.

Udgivet 5. september 202615 min læsning

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.

Stack: Next.js 16, React 19, Lexical 0.50, en drawer bygget på vaul. Alt nedenfor blev verificeret på en fysisk iPhone mod produktionsbuilds; kodeeksemplerne er den leverede kode, skåret ned til essensen.

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.

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]);

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.

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>

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ålidelige visualViewport-begivenheder, og efter tastaturet lukkes, nulstilles offsetTop nogle 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-change og touch-action fra 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:

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`);

É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.

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 });

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.

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>

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.

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-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 < 0 hø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.