مرحباً 
نهج مختلف عن نهج @Damian_Boon: لا يوجد إطار (iframe)، كل شيء يعمل كمكونات Glimmer أصلية تعيد استخدام آلية Post/post-stream الخاصة بـ Discourse. نظراً لأن بعض الأسئلة المحددة ظهرت في الموضوع، إليك كيف انتهيت من حل كل منها.
لماذا لا نستخدم iframe
يعتبر iframe أسرع طريقة لتحقيق ذلك، حيث يوفر مسار الموضوع الكامل، والمحرر، والإدارة، وكل ذلك مجانياً. التكلفة، وهي ما تشير إليه سؤال @nicolsdennis حول MessageBus، هي أنك تنتهي مع نسختين حيتين من Discourse في الصفحة: نهري post-stream، واتصالان بـ MessageBus، ومحرران.
سلكت طريقاً آخر. النافذة المنبثقة هي DModal تقوم بتصيير مكونات Post/PostSmallAction الحقيقية مقابل postStream تم إنشاؤه من store.createRecord("topic", …)، نفس التطبيق، نفس اتصال MessageBus، نفس المحرر. هذا يعني أن حفنة من الخدمات المفردة الأساسية (modal، bookmarkApi، DiscourseURL.routeTo، controller:topic المحيط) تحتاج إلى التوجيه نحو موضوع النافذة المنبثقة طالما أنها مفتوحة:
// تقرأ المكونات الأساسية (مثل PostBookmarkManager) وتكتب topic.bookmarks
// من controller:topic. قم بتوجيهه نحو topicModel الخاص بنا أثناء فتح النافذة المنبثقة.
this.topicController = getOwner(this).lookup("controller:topic");
if (this.topicController) {
this.originalTopicControllerModel = this.topicController.model;
}
يتم التراجع عن كل التصحيحات بواسطة restoreServicePatches() الذي لا يتأثر بالتكرار عند الإغلاق، أو التدمير، أو التنقل بعيداً، لذا لا يتسرب أي شيء إلى بقية التطبيق بمجرد اختفاء النافذة المنبثقة.
الروابط المباشرة والتوجيه الداخلي
جزآن. أولاً، يجب أن تفتح النافذة المنبثقة حيث توقف القارئ فعلياً، وليس دائماً عند المنشور 1:
const lastRead = this.topic.last_read_post_number ?? 0;
const highestPostNumber = this.topic.highest_post_number ?? 1;
const initialPostNumber = Math.max(
1,
Math.min(lastRead + 1, highestPostNumber)
);
ثانياً، الروابط داخل المنشورات التي تشير مرة أخرى إلى نفس الموضوع لا ينبغي أن تخرج من النافذة المنبثقة. يتم تصحيح DiscourseURL.routeTo طوال فترة فتحها:
this.originalRouteTo = DiscourseURL.routeTo.bind(DiscourseURL);
DiscourseURL.routeTo = (path, opts) => {
if (typeof path === "string") {
const match = path.match(/^\/t\/(?:[^/]+\/)?(\d+)(?:\/(\d+))?/);
if (match && parseInt(match[1], 10) === this.topicId) {
this.jumpToPost(match[2] ? parseInt(match[2], 10) : 1);
return Promise.resolve();
}
}
this.restoreServicePatches();
return this.originalRouteTo(path, opts);
};
أي شيء يشير إلى مكان آخر يستعيد التصحيحات أولاً وينتقل إلى تنقل عادي، لذا فإن مغادرة النافذة المنبثقة لا تترك وراءها توجيهًا قديماً.
حالة الرد (20+ رد)
تمرير لا نهائي في الأسفل عبر عنصر استشعار (sentinel) + IntersectionObserver، و"تحميل المنشورات السابقة" متماثل في الأعلى (يمكن فتح المعاينة في منتصف سلسلة المنشورات)، وكلاهما يدفع نفس postStream.appendMore() / prependMore() الذي يستخدمه مسار الموضوع الحقيقي:
sentinel = modifier((element) => {
const obs = new IntersectionObserver(
(entries) => {
if (entries.some((e) => e.isIntersecting)) {
this.loadBelow();
}
},
{
root: document.querySelector(".topic-preview-modal .d-modal__body"),
rootMargin: "200px",
}
);
obs.observe(element);
return () => obs.disconnect();
});
الرسوم الأولي
يملأ الهيكل العظمي (صورة رمزية + عناصر نائبة للأسطر، وميض) الرسوم الأولي بدلاً من مؤشر التحميل، وقائمة المنشورات نفسها يتم تصييرها على مراحل بدلاً من دفعة واحدة، منشورات كافية للوصول إلى المنشور المستهدف أولاً، ثم خمسة إضافية في كل مرة عبر requestIdleCallback حتى يلحق الباقي:
const renderRemainingPosts = () => {
const totalPosts = this.postStream?.posts?.length || 0;
if (this.renderLimit < totalPosts) {
this.renderLimit = Math.min(this.renderLimit + 5, totalPosts);
if (this.renderLimit < totalPosts) {
if (window.requestIdleCallback) {
window.requestIdleCallback(renderRemainingPosts, { timeout: 300 });
} else {
setTimeout(renderRemainingPosts, 30);
}
return;
}
}
this.renderLimit = Number.MAX_SAFE_INTEGER;
};
تحصل كل غلاف منشور أيضاً على content-visibility: auto مع contain-intrinsic-size تقريبي، لذا يتخطى المتصفح التخطيط بالكامل للمنشورات التي تكون خارج الشاشة:
.topic-post {
contain: layout;
content-visibility: auto;
contain-intrinsic-size: 1px 180px;
}
هذه هي النقطة التي سأدفع فيها بقوة. بدلاً من إعادة استخدام نسخة بعد النقر، أقوم بالتحميل المسبق قبل النقر. كل صف في قائمة الموضوع يراقب مرئيته الخاص، يؤخر التنفيذ 200 مللي ثانية حتى لا يؤدي التمرير السريع إلى إطلاق طلبات عديدة، ويتولى طابوراً مشتركاً صغيراً محدوداً بطلبين متزامنين:
function createTopicPrefetchQueue(maxConcurrent) {
const pending = [];
const activeIds = new Set();
function drain() {
while (activeIds.size < maxConcurrent && pending.length) {
const { id, task } = pending.shift();
activeIds.add(id);
Promise.resolve()
.then(task)
.finally(() => {
activeIds.delete(id);
drain();
});
}
}
return {
schedule(id, task) {
if (activeIds.has(id) || pending.some((item) => item.id === id)) {
return;
}
pending.push({ id, task });
drain();
},
cancel(id) {
const index = pending.findIndex((item) => item.id === id);
if (index !== -1) {
pending.splice(index, 1);
}
},
};
}
يتم تخزين التحميل المسبق تحت مفتاح خاص PreloadStore، عمداً ليس المفتاح الأساسي topic_${id}، فإن الكتابة هناك ستسرب JSON جزئي النطاق last_read+1 إلى تنقل موضوع كامل حقيقي. عند النقر الفعلي يتم ترقيته إلى المفتاح الذي يتوقعه محمل النافذة المنبثقة:
promoteTopicPrefetch(topic) {
if (!topic?.id) {
return;
}
try {
const key = this.prefetchStoreKey(topic.id);
const prefetched = PreloadStore.get?.(key);
if (prefetched != null) {
PreloadStore.remove?.(key);
PreloadStore.store(`topic_${topic.id}`, prefetched);
}
} catch {
// جهد قصوى - تعود النافذة المنبثقة إلى تحميل جديد
}
}
إذا خرج صف من الشاشة قبل النقر عليه، يتم إلغاء الطلب المعلق وتجاهل أي نتيجة مخزنة مؤقتاً، لذا لا يحتفظ إلا بالبيانات للموضوعات الظاهرة فعلياً على الشاشة الآن.
شيء آخر، لم يتم طرحه في الموضوع بعد: النوافذ المنبثقة المتداخلة
بعض تدفقات النوافذ المنبثقة (التي يفتحها النظام الأساسي بدلاً من خلال خاصية استدعاء صريحة مربوطة بـ showSubModal()) تستدعي خدمة modal مباشرة. بدلاً من السماح لها بإغلاق نافذة المعاينة من تحت القارئ، يتم تصحيح modal.show() لإعادة توجيه النوافذ المنبثقة المستهدفة بشكل انتقائي إلى فتحة نافذة فرعية محلية صغيرة طالما أن المعاينة مفتوحة:
this.originalModalShow = this.modal.show.bind(this.modal);
this.modal.show = (component, opts = {}) => {
return this.showSubModal(component, opts.model);
};
لذا فإن هذه التدفقات المستهدفة تتراكم فوق المعاينة بدلاً من استبدالها، بينما يستمر تتبع القراءة (/topics/timings) في العمل في الخلفية عبر تفريغ دوري لمجموعة أرقام المنشورات المرئية التي يتتبعها IntersectionObserver، لذا تكون عدادات غير المقروءة صحيحة بعد الإغلاق، تماماً مثل زيارة موضوع عادي.
إنها قطعة عادلة من الكود عبر النافذة المنبثقة، ومُفعّل قائمة الموضوع، والتنسيق (~1700 سطر معاً)، هذا يغطي ثلثها تقريباً. كانت أصعب الأجزاء من بعيد هي تصحيح الخدمات وجعل content-visibility يتصرف بشكل صحيح بمجرد تغير ارتفاعات المنشورات بعد انتهاء تحميل الصور. سعيد بالدخول في مزيد من التفاصيل حول أي منها، أو جعله مفتوح المصدر، إذا كان هناك اهتمام.