تخطَّ إلى المحتوى

anchor مرساة —— تنقل

المرساة هي «شريط دليل جانبي للوثائق الطويلة»: في الجانب الأخر من موقع التوثيق تجد «الاستخدام الأساسي / API / ملاحظات»، وعند النقر تنتقل إلى العنوان المقابل، وعند التمرير يُبرز القسم الحالي. وفرقها عن menu هو أن المرساة ترتبط بموضع محتوى داخل الصفحة نفسها، وليس بصفحات مختلفة.

  • لتطبيق <LhAnchor> في كل طرف: أدلة مواقع التوثيق، وأدلة القراءة للمقالات الطويلة، وتنقّل فصول وثائق API، وتنقّل أقسام الصفحات الرئيسية.
  • عندما يكون محتوى الصفحة طويلاً بما يستدعي قفزًا سريعًا، وللأقسام عناوين واضحة.
  • لا ينبغي استخدامه: التنقّل بين صفحات الموقع (استخدم menu / breadcrumb)؛ الصفحات القصيرة (لا حاجة لدليل في صفحة بشاشة واحدة)؛ الأقساع بلا عناوين (تعتمد المرساة على نص العنوان كنص للرابط).
import {
flattenAnchorItems,
collectAnchorIds,
resolveActiveAnchor,
getAnchorClassNames,
getAnchorLinkClassNames,
} from '@lh-ui/share/core/anchor'
import type { LhAnchorItem, LhFlatAnchorItem } from '@lh-ui/share/core/anchor'

بنية شجرية → قائمة مسطّحة: تكوين المرساة شجري (أقسام تتوّج أقسامًا فرعية)، لكن التكوين المعروض قائمة أحادية البعد (مع مسافة بادئة). تعبر flattenAnchorItems بأولوية العمق محافظةً على ترتيب الوثيقة——فالدليل المرتبب بشكل خاطئ أسوأ من عدم وجود دليل. ويسجّل level العمق الأصلي ليحرّك أنماط المسافة البادئة.

سلسلة الأسلاف seen مضادّة لمراجع الحلقات: seen هي مجموعة الأسلاف على مسار DFS الحالي (وليست مجموعة شاملة). ويذكر التعليق صراحةً «حماية سلسلة الأسلاف لاكتشاف مراجع الحلقات»——إذا ضمّت بيانات أطفالَ عنصر A العنصر A نفسه (بيانات قذرة)، فإن المجموعة الشاملة تُزيل التكرار فقط، بينما سلسلة الأسلاف تقطع العودية مباشرةً فلا تحدث حلقة لا نهائية. وهذا ضمان ضد الأخطاء النوعية في البنى الشجرية.

resolveActiveAnchor «الlast الذي تجاوز العتبة»: يجد في إزاحات مرتّبة بترتيب الوثيقة آخر مرساة ذات offset <= currentTop. والتفصيل الجوهري: عند اصطدام أول offset > currentTop يتم break——لأن الإزاحات مرتّبة، فما بعدها أكبر فقط، ومتابعة الاجتياز إهدار. هذا تنفيذ O(n) لكنه مع إنهاء مبكّر.

عدم الإصابة يعود إلى العنصر الأول: عند التمرير إلى ما قبل كل المراسي (أعلى الصفحة) يُرجع ids[0]——يظل العنصر الأول مبرّزًا دائمًا، لتجنّب الحالة المحيّرة «لا يوجد عنصر مبرّز». وتُرجع القائمة الفارغة سلسلة فارغة (لا تُبرز طبقة واجهة المستخدم شيئًا).

offsets و ids يجب أن يكونا بنفس الترتيب: يتطلّب توقيع الدالة مصفوفتين——تُقاس الإزاحات من طبقة واجهة المستخدم عبر getBoundingClientRect() لكل عنصر وتُمرَّر بالترتيب نفسه. واختلال الترتيب يعطي عنصرًا مبرّزًا خاطئًا، وهذا عقد استدعاء لا يمكن لـ core التحقق منه.

الدالة التوقيع الناتج الدور
flattenAnchorItems (items, level?, seen?) => LhFlatAnchorItem[] قائمة مسطّحة أولوية العمق مع الحفاظ على الترتيب
collectAnchorIds (items) => string[] قائمة id مصفوفة id بترتيب المسطّح
resolveActiveAnchor (ids, offsets, currentTop) => string id النشط تحديد الارتباط مع التمرير
getAnchorClassNames (state: { direction }) => string[] أسماء الأصناف اتجاه الحاوية
getAnchorLinkClassNames (state: { active, level }) => string[] أسماء الأصناف الحالة النشطة + مستوى المسافة البادئة
export interface LhAnchorItem {
id: string // id للعنصر الهدف
title: string // النص الظاهر
children?: LhAnchorItem[]
}
export interface LhFlatAnchorItem {
id: string
title: string
level: number // العمق الأصلي (الجذر 0)، يحرّك المسافة البادئة
}

تشير روابط المراسي إلى مراسي داخل الصفحة نفسها، والطريقة المعيارية هي عنصر <a href="#section"> الأصلي:

<a href="#usage" class="lh-anchor__link">مثال الاستخدام</a>
...
<section id="usage" tabindex="-1">…</section>
  • قفز مرساة أصلي + tabindex=“-1”: عند النقر على <a href="#id"> يمرّر المتصفح ويحاول تركيز الهدف. وعندما لا يكون الهدف قابلًا للتركيز، يبقى التركيز ثابتًا بعد القفز——فإضافة tabindex="-1" للهدف تجعله قادرًا على استقبال التركيز البرمجي، فيبدأ قارئ الشاشة القراءة منه. وهذا هو نمط WAI-ARIA المعتمد للمراسي سهلة الوصول.
  • aria-current لتمييز القسم الحالي: --active الخاص بـ getAnchorLinkClassNames صنف بصري، ويُستحسن من ناحية إمكانية الوصول أن تضيف طبقة واجهة المستخدم aria-current="location" للرابط النشط (دلالة الموضع في الصفحة، تمييزًا لها عن aria-current="page" الخاص بـ breadcrumb).
  • القفز دون تأثير جانبي على عنوان URL: يدخل تغيير الـ hash في تأريخ المتصفح، فبعد خمس نقرات على المراسي تحتاج إلى خمس ضغطات على زر الرجوع لمغادرة الصفحة. يمكن لطبقة واجهة المستخدم استخدام history.replaceState أو scrollIntoView بدلاً من القفز الافتراضي——وهذا قرار تفاعلي لا تعنيه core.
  • تغليف nav + aria-label: مثل breadcrumb، يصرِّح <nav aria-label="محتوى هذه الصفحة"> عن كونها منطقة تنقّل.
import {
flattenAnchorItems, collectAnchorIds, resolveActiveAnchor,
} from '@lh-ui/share/core/anchor'
const items = [
{ id: 'intro', title: 'مقدمة', children: [
{ id: 'why', title: 'لماذا تستخدمه' },
] },
{ id: 'usage', title: 'الاستخدام' },
]
flattenAnchorItems(items)
// [ { id: 'intro', title: 'مقدمة', level: 0 },
// { id: 'why', title: 'لماذا تستخدمه', level: 1 },
// { id: 'usage', title: 'الاستخدام', level: 0 } ]
collectAnchorIds(items) // ['intro', 'why', 'usage']
// الارتباط مع التمرير: offsets هي مواضع المراسي نسبةً إلى أعلى الحاوية (بنفس ترتيب ids)
resolveActiveAnchor(['intro', 'why', 'usage'], [0, 300, 600], 350) // 'why'
resolveActiveAnchor(['intro', 'why', 'usage'], [0, 300, 600], 50) // 'intro' (لم يتجاوز why)
resolveActiveAnchor(['intro', 'why', 'usage'], [0, 300, 600], 0) // 'intro' (عدم إصابة يعود للأول)
resolveActiveAnchor([], [], 0) // '' (قائمة فارغة)
// طبقة واجهة المستخدم: مراقبة التمرير + قياس الإزاحات
const activeId = ref('')
function refresh() {
const flat = flattenAnchorItems(props.items)
const ids = flat.map((item) => item.id)
const offsets = ids.map((id) => {
const el = document.getElementById(id)
return el ? el.getBoundingClientRect().top - containerTop : Infinity
})
activeId.value = resolveActiveAnchor(ids, offsets, scrollTop.value)
}
onMounted(() => window.addEventListener('scroll', refresh, { passive: true }))
<!-- Vue3 -->
<template>
<LhAnchor :items="items" :offset-top="80" @click="onAnchorClick" />
</template>
// React: useEffect يُسجِّل مراقبة scroll؛ ويُقاس الإزاحات عبر مصفوفة ref + getBoundingClientRect؛
// ويُستخدم requestAnimationFrame أو throttle للتخفيف (scroll يعمل بتكرار عالٍ)
// uniapp: `<scroll-view scroll-into-view="id">` هو قفز المراسي الأصلي في
// التطبيقات المصغّرة؛ ويُؤخذ scrollTop من حدث scroll الخاص بـ scroll-view
اسم الصنف الدلالة
.lh-anchor الحاوية
.lh-anchor--{vertical|horizontal} الاتجاه (شريط جانبي عمودي / شريط علوي أفقي)
.lh-anchor__link رابط المرساة
.lh-anchor__link--level-{0..n} مستوى المسافة البادئة (padding-inline-start يتزايد)
.lh-anchor__link--active الحالة النشطة (اللون الأساسي + شريط مؤشر على جانب البداية)

لون التفعيل --lh-primary-color؛ الروابط العادية --lh-text-regular؛ وحدة المسافة البادئة --lh-anchor-indent (مثل 12px/مستوى).

  • قياس الإزاحة يجب أن يطرح إزاحة الحاوية: إذا كانت المراسي داخل حاوية ذات تمرير داخلي (وليس تمرير window)، يجب طرح موضع أعلى الحاوية من getBoundingClientRect().top، وإلا سيظل الإبراز منحرفًا. هذا أكثر الأخطاء شيوعًا.
  • getBoundingClientRect يجبر التخطيط: استدعاؤها على N عنصرًا في كل حدث scroll يُطلق N إعادة ترتيب. التحسين: تخزين الإزاحات مؤقتًا وإعادة الحساب فقط عند تغيّر أبعاد المحتوى؛ أو استخدام IntersectionObserver لمراقبة كل عنصر على حدة.
  • تعويض إزاحة الحجب العلوي: عندما تكون للصفحة ترويسة مثبّتة (affix)، قد يحجب الترويسةَ الهدفَ بعد قفز المرساة. أضِف scroll-margin-top (CSS) للعنصر الهدف أو اطرح ارتفاع الترويسة من قيمة التمرير في طبقة واجهة المستخدم——وهذه نقطة تعاون في التخطيط.
  • انتهاء صلاحية الإزاحات مع المحتوى الديناميكي: عند تغيّر ارتفاع المحتوى (توسيع/طي، اكتمال تحميل الصور) تصبح الإزاحات المخزّنة مؤقتًا خاطئة. يجب إعادة القياس عند تغيّر المحتوى.
  • دلالة سلسلة الأسلاف لـ seen: المعامل الثالث seen الخاص بـ flattenAnchorItems هو الأسلاف على المسار، وافتراضه new Set(). لا يمرّره جانب العمل عادةً——فهو أساسًا لمنع الحلقات اللانهائية في البيانات القذرة. وإذا أحالت شجرتان شقیقتان إلى العقدة نفسها (مشاركة DAG)، فستجعل سلسلة الأسلاف العقدة تظهر مرة واحدة في كل مسار (وهو انتخاب القسم نفسه بين سلسلة الأسلاف في tree وseen الشاملة في table).
  • id الفارغ و id المكرّر: يجب أن يكون id فريدًا عالميًا (متطلّب مراسي HTML). ولا يُرجع document.getElementById سوى المطابقة الأولى——المعرفات المكررة تسبّب قفزًا خاطئًا، ولا يمكن لـ core التحقق من ذلك.
  • المرساة مقابل توجيه الدليل: تربط المرساة مواضع في الصفحة نفسها؛ وإذا كان كل عنصر من «الدليل» صفحة مختلفة، فهو menu لا anchor——لا تخلط بينهما (المرساة لا تُحدّث الصفحة، وتختلف في طريقة إدارة تأريخ التوجيه).
@lh-ui/share/core/anchor → resolveActiveAnchor( )
返回值
(填好参数后点运行)

Copyright © 2024-2026 FengMo WenXue