أربعة أخطاء قتلت تحديد النص على iOS — وكل واحد كان يخفي التالي
محرّر Lexical داخل drawer من vaul كان يعمل في كل مكان إلا على iPhone حقيقي: مقابض التحديد لا تنسحب، والتمرير يقفل عند الحواف، وشريط الأدوات يغوص تحت لوحة المفاتيح. التخمين لم يصلح شيئًا، لأنه لم يكن هناك مذنب واحد — بل أربعة مستقلون، وكلٌّ منهم يخفي التالي. ما نجح هو منصّة التنصيف: الهيكل نفسه مع مفتاح لكل مشتبه به، مُختبَر على جهاز فعلي. هذه هي المطاردة كاملة — الـ href الذي يسمّم التحديد، ومعالجات السحب في vaul، وtap-focus الخاص بنا، وحاوية تتحرك تحت الإصبع — إضافة إلى تشريح قفل التمرير والكتابات الثلاث الوحيدة لـ scrollTop التي جعلت الـ rubber-band يبدو أصليًا، بما في ذلك التقاط سحبة في منتصف الارتداد.
محرّر النصوص المحمول في landee هو Lexical داخل drawer من vaul: تلمس كتلة نص، فيصعد drawer بملء الشاشة، تحرّر، وتغلقه علامة صح. على سطح المكتب وفي المحاكي كان كل شيء بخير. على iPhone فعلي كانت كارثة بعدة وجوه مستقلة: مقابض التحديد — الدبّوسان الأزرقان اللذان يمنحهما iOS لمطّ التحديد — لا ينسحبان إطلاقًا، أو فقط بعد إمساك نحو ثانيتين؛ اسحب المقبض الأيمن نحو اليسار فينهار التحديد كله؛ التمرير يقفل في الأسفل حتى تشدّ أكثر نزولًا كأنك «تفكّه»؛ وشريط الأدوات يغوص تحت لوحة المفاتيح كلما وضع الجهاز فوقها شريطًا إضافيًا.
هذه سيرة العلاج — الطريق، لا الإصلاحات وحدها، لأن الطريق هو الجزء القابل للنقل. الدرس الرئيسي: حين «لا يعمل التحديد» على iOS، لا يوجد مذنب واحد يُبحث عنه. كانوا أربعة، مستقلين بعضهم عن بعض، وكلٌّ منهم يقنّع التالي — تصلح واحدًا فتتغيّر الأعراض بما يكفي بالضبط لتبدو الفرضية التالية خاطئة.
الطريقة التي فشلت، والطريقة التي نجحت
ذهبت الساعات الأولى في التخمين: ربما طبقة transform للـ drawer، ربما قناع CSS على المُمرِّر، ربما أوامر التحديد في Lexical. كل تخمين أنتج رقعة معقولة الشكل وصفر تغيير على الجهاز. رقعتان منها تبيّن لاحقًا أنهما خطآن بذاتهما: كاتم على SELECTION_CHANGE_COMMAND في Lexical، وحاجز فوق طرق الكتابة في Selection.prototype جعل الكتابة تهبط في بداية السطر — اللمسة لم تعد توصل المؤشر.
ما نجح هو التنصيف على منصّة معزولة: صفحة تصحيح بهيكل المحرّر نفسه — نفس drawer الـ vaul، نفس العمود، نفس المُمرِّر — وزرّ مفتاح لكل مشتبه به. الـ contenteditable العاري كان هناك مثاليًا. كل مفتاح يعيد طبقة واحدة حتى ينكسر شيء. أربع جولات على هاتف حقيقي أدانت أربعة أشياء بالضبط وبرّأت كل ما عداها — قفل الـ body، الأقنعة، طبقات الـ transform، دور الحوار — مشتبهون كانوا سيظلون «يُصلَحون» أيامًا.
قاعدتان أبقتا المنصّة صادقة. لا يُحتسب إلا جهاز فعلي: المحاكي بلا لوحة مفاتيح حقيقية وبلا الأشرطة الإضافية فوقها، ونصف الأخطاء يسكن هناك بالضبط. ولا تُحتسب إلا بناءات الإنتاج: حزم التطوير ثقيلة إلى حدّ أن WebKit يقتل التبويب من نقص الذاكرة، فيبدو كل شيء مكسورًا قبل أن تكتمل الـ hydration أصلًا.
المذنب الأول: href على رابط داخل contenteditable
بالنسبة إلى iOS فإن <a href> تفاعلي حتى داخل نص قابل للتحرير: اللمسة تبدأ إيماءة «انقر الرابط»، وتلك الإيماءة تعلو على إيماءة التحديد — لحظة يلمس المقبض رابطًا ينهار التحديد. المرساة نفسها بدون href مجرّد نص. -webkit-touch-callout: none لا ينقذ. والـ contenteditable العاري بلا أي مكتبة يتسمّم بالطريقة نفسها تمامًا — إذًا Lexical بريء.
العلاج يزيل href من الـ DOM الحي للمحرّر فور أن يرسم Lexical عقدة رابط أو يحدّثها. لا خسارة في أي مكان: الملاحة معطّلة أصلًا في وضع التحرير، والعنوان يعيش في عقدة Lexical ويبقى قابلًا للتحرير، والـ HTML المحفوظ يحتفظ بـ href كما كان — التصدير يمرّ عبر exportDOM الذي يبني عناصره الخاصة ولا يرى الـ 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]);المذنب الثاني: معالجات السحب في vaul على محتوى الـ drawer
يُبقي vaul منطق «اسحب لتغلق» معلّقًا على محتوى الـ drawer حتى مع handleOnly — وهو بالذات ما كان يقتل الإمساك بالمقابض. البرهان كان مصفوفة على المنصّة: Lexical بروابط داخل drawer مزيّف بنفس الهندسة — مثالي؛ داخل vaul الحقيقي — ميت. ثم، تغيير واحد في كل مرة: نزع أنماط vaul (transform وwill-change وtouch-action) — ما زال ميتًا؛ إزالة دور الحوار وسمات aria — ميت؛ فكّ قفل الـ body — ميت؛ إسكات الأحداث كي لا تصل معالجات vaul — حيّ تمامًا.
الكاتم هو stopPropagation لا preventDefault: المتصفح وLexical يسمعان كل شيء لأن مستمعيهما أعمق في الشجرة — وحده vaul يُصاب بالصمم. ويجب أن يغطي أحداث الـ pointer والـ touch معًا: أظهرت المصفوفة أن صمت الـ pointer وحده لا يكفي — تبقى المقابض ميتة ما دام vaul يسمع الـ 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>المذنب الثالث: الـ tap-to-focus الخاص بنا
كان hook مساعد يفرض focus() عند كل لمسة أقصر من 300 ملّي ثانية — والإمساك السريع بمقبض تحديد هو بالضبط لمسة قصيرة. هذا يفسّر العرض الأسطوري «أمسك ثانيتين ثم ينسحب»: الضغطة الطويلة ليست نقرة، فلا ينطلق focus() وينجو السحب. العلاج سطر واحد — استدعِ focus() فقط حين لا يحتوي المحرّر على التركيز أصلًا.
المذنب الرابع: حاوية تتحرك تحت الإصبع
العمارة الأولى كانت تغيّر حجم الـ drawer كله ليتّسع فوق لوحة المفاتيح. أثناء التحديد يزيح iOS الشاشة — فيتبع الـ drawer الإزاحة، ويهرب النص من تحت الإصبع، ويُسقط WebKit السحب. الإصلاح «البديهي» — تجميد الـ drawer ما دام إصبع نازلًا — فشل بطريقة أخرى: انزاح النص نسبةً إلى الإصبع بمقدار الإزاحة بالضبط، فحطّ التحديد على سطرٍ غير المقصود.
القاعدة التي وُلدت من هذا: لا يجوز تحريك إلا ما لا يُلمَس. الـ drawer والنص واقفان على كامل ارتفاع منفذ الـ layout — على iOS لا تضغط لوحة المفاتيح الـ layout، بل تغطي أسفله فقط. عمليًا:
- شريط الأدوات وحده يطارد لوحة المفاتيح؛ هامش النص السفلي يسافر عبر متغيّر CSS (
--keyboard-inset) — لا شيء يتحرك. - حلقة إطارات (
requestAnimationFrame) لا أحداث: يزيح iOS أثناء التحديد بلا أحداثvisualViewportموثوقة، وبعد إغلاق لوحة المفاتيح لا يعودoffsetTopأحيانًا إلى الصفر أبدًا (نكوص iOS 26). حلقة تنظر كل إطار لا تحتاج إشارة. - الأنماط تُكتب مباشرة في الـ DOM متجاوزةً React — عبر الحالة كان كل إطار إزاحة سيعيد رسم شجرة Lexical كلها.
- الغلاف هو الذي يتمرّر، لا الـ contenteditable. ساحة لعب Lexical نفسها تفعل المثل (
editor-scroller): contenteditable مركّز يتمرّر بنفسه هو أسوأ تكوين يعرفه WebKit — الإيماءة تارة تمرّر وتارة تبدأ تحديدًا وتارة تُطعم الصفحة. - بعد نصف ثانية من الفتح تُنزع من الـ drawer أنماط vaul:
transformوwill-changeوtouch-action— الطبقة المركّبة تزيح منطقة لمس المقابض، ولم تعد حركة الفتح تحتاج تلك الأنماط.
الشريط فوق لوحة المفاتيح — وفوق كل ما يضعه iOS فوق لوحة المفاتيح
«100svh ناقص لوحة المفاتيح» كذبة على iPhone: شريط العنوان يأخذ حصته، والجهاز يحب وضع لوحات إضافية فوق لوحة المفاتيح. الحقيقة يعرفها visualViewport، والرقم الذي يحتاجه الشريط هو كم مِن أسفل منفذ الـ layout محجوز:
// 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 أن الحقل «في الأعلى» ويترك الصفحة وشأنها.
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. في الأسفل كان الشريط المرئي فوق لوحة المفاتيح منطقةً قابلة للتحرير فارغة — وiOS يعامل لمس contenteditable مركّز على أنه عمل بالنص لا تمرير. المساحة للوحة المفاتيح يجب أن تكون كتلة منفصلة غير قابلة للتحرير بعد الـ contenteditable — لا padding له.
- «صمّامنا» نحن، المبني ضد السبب الأول. قرب الحافة يتجوّل scrollTop بقيم كسرية، وكان حارس كل إطار «يصحّحه» بكتابات برمجية — والكتابات البرمجية المتواصلة للتمرير تقتل إيماءة الإنسان. مع لوحة مفاتيح مفتوحة صار الصمّام نفسه هو القفل.
القاعدة: لا preventDefault عند الحواف ولا حرّاس scrollTop، أبدًا. غلاف overflow-y-auto بسيط والفاصل خارج الـ contenteditable يتمرّر بلا عيب — إضافةً إلى overscroll-contain كي لا تُطعم إيماءةُ الحافة الصفحةَ التي يجعلها iOS قابلة للتمرير من أجل الحقل المركّز.
<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)، لأن الحركة لا تعمل إلا حين لم يكن أي إصبع ممسكًا بالمُمرِّر، وإصبع ثانٍ يجب ألا ينتزع النص من تحت الأول.
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— يزيل المشكلة مع النابض نفسه: يموت الزخم عند الحافة موتًا صلبًا، وتُحَسّ الحافة مبتورة.
المبرَّؤون — لا تطاردهم ثانية
- قفل الـ body بـ
position: fixedمن vaul - قناع التلاشي CSS على المُمرِّر
- طبقة الـ transform بذاتها (نزعها صحيح، لكنها لم تكن القاتلة)
- دور الحوار وسمات aria وdata للـ drawer
- Lexical نفسه — الـ contenteditable العاري يستنسخ سمّ الـ href وقفل التمرير كليهما
- إسكات
SELECTION_CHANGE_COMMANDفي Lexical — لم يغيّر شيئًا - الحاجز فوق كتابات
Selection.prototype— صار خطأً بذاته: اللمسة كفّت عن إيصال المؤشر وذهبت الكتابة إلى بداية السطر
عدوّ إضافي: التحديد الأصلي ضد حقل العنوان
تحرير الرابط يحمل صراعًا مدمجًا: تحديد iOS الأصلي لا يعيش إلا في محرّر مركّز، وحقل العنوان يحتاج التركيز لنفسه — لا اجتماع لهما. لذا يُلفّ النص المحدد طوال مدة التحرير في MarkNode من Lexical: إبراز حقيقي في الـ DOM لا يعبأ بمكان التركيز. بالآلية نفسها تُبرز ساحة لعب Lexical أهدافَ التعليقات.
التحديد نفسه يُحفَظ باستمرار، عند كل تغيّر — نمط lastSelection ذاته الموجود في FloatingLinkEditorPlugin لدى Lexical — لأن الحفظ لحظة ضغط الزر متأخر: لمسة زر الشريط تُسقط التحديد أولًا. وعند التطبيق يعيد فكُّ العلامة بناءَ التحديد الحقيقي في مكانه بالضبط، ويتولى TOGGLE_LINK_COMMAND الباقي.
الخلاصة
- «التحديد لا يعمل على iOS» ليس خطأً واحدًا. كانت أربعة أسباب مستقلة، كلٌّ يقنّع التالي — تصحيح الأخطاء بفرضية واحدة لا يمكن أن يتقارب هنا.
- التنصيف يهزم الاستنباط. منصّة معزولة بمفتاح لكل مشتبه به حوّلت أيام التخمين إلى أربع جولات على الجهاز — وبرّأت المشتبهين الذين كانوا سيُصلَحون أيامًا.
- الحقيقة لا يقولها إلا جهاز فعلي: المحاكي بلا لوحة مفاتيح حقيقية ولا أشرطة إضافية، وحزم التطوير تموت من الذاكرة قبل ظهور الأخطاء أصلًا.
- حرّك فقط ما لا يُلمس: drawer ساكن، شريط على حلقة إطارات، هامش سفلي عبر متغيّر CSS.
- لا تحارب تمرير iOS أبدًا بكتابات متواصلة أو preventDefault عند الحواف — كلاهما يصير القفل ذاته الذي أُريد منعه. كل علاج نجا هو كتابة scrollTop واحدة في لحظة مكشوفة بدقة.
- بروتوكول rubber-band كاملًا: دفعة عند touchstart على حافة ساكنة، دفعة تهدئة مؤجّلة بعد الزخم، وحصر عند touchstart في منتصف الارتداد — قابل للكشف لأن iOS يكشف scrollTop خارج الحدود.
لاحظت خطأً؟
معلومة خاطئة، ترجمة ركيكة، أو شيء يبدو غير صحيح في هذه المقالة؟ راسلني — بلغتك أنت.