לחובבי הסקריפטים
המרתי
// ==UserScript==
// @name גלילה רציפה - פרוג ועוד
// @namespace https://freebuff.local/glila-rezifa
// @version 1.0.0
// @description גרסת סקריפט (Tampermonkey) לתוסף "גלילה רציפה": קריאת עמודים ברצף בגלילה — טוען אוטומטית את העמוד הבא/הקודם בלי ללחוץ. תומך בפרוג ובאתרים נוספים.
// @author גרסת סקריפט המבוססת על התוסף המקורי
// @match *://*/*
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_addStyle
// @grant GM_registerMenuCommand
// @grant GM_prompt
// @run-at document-idle
// @noframes
// ==/UserScript==
// ============================================================================
// גלילה רציפה — גרסת סקריפט (Tampermonkey / Violentmonkey / Greasemonkey)
// המרה מלאה מתוסף Chrome "גלילה רציפה - פרוג ועוד" (manifest v3).
//
// מה הוחלף מול התוסף:
// * chrome.storage.sync → GM_setValue / GM_getValue
// * הפופאפ ועמוד ההגדרות → תפריט הסקריפט (GM_registerMenuCommand):
// - מצב הגלילה: קדימה+אחורה / רק קדימה / רק אחורה / כבוי
// - הוספת אתר / הסרת האתר הנוכחי / איפוס הגדרות
// * chrome.runtime.onMessage → פקודות תפריט ישירות (ללא רענון)
// * content.css → GM_addStyle
// * extsync-bridge (עדכונים אוטומטיים) → לא רלוונטי לסקריפט
// מנוע הגלילה עצמו (ContinuousReader) נותר זהה לחלוטין לתוסף.
// ============================================================================
(function () {
"use strict";
// אם הסקריפט כבר רץ בעמוד הזה - לא מפעילים שוב.
if (window.__continuousReaderLoaded) return;
window.__continuousReaderLoaded = true;
// ---------------------------------------------------------------------------
// סטיילים (הועתקו מ-content.css של התוסף)
// ---------------------------------------------------------------------------
GM_addStyle(`
/* מפריד בין עמודים שנטענו ברצף — עיצוב ברור: קו + תגית עמוד באמצע */
.cr-page-separator {
display: flex;
align-items: center;
gap: 12px;
margin: 30px 0;
font-family: system-ui, "Segoe UI", Arial, sans-serif;
user-select: none;
direction: rtl;
}
.cr-page-separator::before,
.cr-page-separator::after {
content: "";
flex: 1 1 auto;
height: 2px;
background: linear-gradient(to left, transparent, #c7cdf0);
}
.cr-page-separator::after {
background: linear-gradient(to right, transparent, #c7cdf0);
}
/* כשגוללים לתחילת עמוד חדש שנטען, רוצים שהתגית תיראה מיד למעלה
בלי מרווח גדול מעליה. */
.cr-page-section > .cr-page-separator:first-child {
margin-top: 4px;
}
/* בטעינה אחורה התגית נמצאתית ה-section (בתפר מול התוכן הישן) —
רוצים שהיא תיראה קרוב לתחתית המסך בלי מרווח גדול מתחתיה. */
.cr-page-section > .cr-page-separator:last-child {
margin-bottom: 4px;
}
.cr-page-badge {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 6px;
background: #4a5bdc;
color: #fff;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.2px;
padding: 6px 16px;
border-radius: 999px;
box-shadow: 0 3px 10px rgba(74, 91, 220, 0.35);
white-space: nowrap;
}
.cr-page-badge .cr-page-badge-icon {
font-size: 13px;
line-height: 1;
}
.cr-page-section {
/* אנימציית הופעה עדינה */
animation: cr-fade-in 0.35s ease;
}
@keyframes cr-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
/* חיווי צף — מוצג לזמן קצר בלבד בזמן פעולה, ונעלם לבד כדי לא להפריע
לקריאה כשגוללים עמוק בתוך התוכן. */
.cr-indicator {
position: fixed;
bottom: 18px;
left: 18px;
z-index: 2147483647;
background: rgba(30, 34, 45, 0.92);
color: #fff;
font-family: system-ui, "Segoe UI", Arial, sans-serif;
font-size: 12px;
padding: 7px 12px;
border-radius: 20px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.25);
direction: rtl;
pointer-events: none;
opacity: 0;
transform: translateY(6px);
transition: opacity 0.25s ease, transform 0.25s ease;
}
.cr-indicator-show {
opacity: 0.92;
transform: translateY(0);
}
`);
// ---------------------------------------------------------------------------
// שכבת התאמה לסקריפט — במקום chrome.storage.sync
// ---------------------------------------------------------------------------
// אחסון פשוט על גבי GM_setValue/GM_getValue (ערכים כקובצי JSON).
const store = {
async get(keys) {
const out = {};
for (const k of keys) {
const raw = GM_getValue("cr_" + k, undefined);
if (raw === undefined) continue;
try {
out[k] = JSON.parse(raw);
} catch (e) {
out[k] = raw; // ערך ישן/לא-JSON — מחזירים כמו שהוא.
}
}
return out;
},
async set(obj) {
for (const [k, v] of Object.entries(obj)) {
GM_setValue("cr_" + k, JSON.stringify(v));
}
}
};
// תיבת קלט ידידותית לסקריפט (GM_prompt), עם גיבוי ל-prompt רגיל.
function gmPrompt(message, def) {
return new Promise((resolve) => {
try {
const r = GM_prompt(message, def || "");
if (r && typeof r.then === "function") { r.then(resolve); return; }
resolve(r);
} catch (e) {
resolve(window.prompt(message, def || ""));
}
});
}
// הגדרות ברירת מחדל (מהתוסף — background.js).
const DEFAULT_SETTINGS = {
// כיוון הגלילה הרציפה:
// "both" = קדימה ואחורה
// "forward" = רק קדימה (טעינת העמוד הבא)
// "backward" = רק אחורה (טעינת העמוד הקודם)
// "off" = כבוי
direction: "both",
thresholdForward: 1200, // כמה פיקסלים לפני סוף העמוד להתחיל לטעון קדימה
thresholdBackward: 800, // כמה פיקסלים מראש העמוד להתחיל לטעון אחורה
showIndicator: true // הצגת חיווי צף על המסך
};
// פרוג בנוי על מנוע פורומים XenForo, ולכן משתמשים במצב הייעודי "xenforo"
// עם סלקטורים מדויקים — כך הטעינה מהירה ומדויקת.
// הערה: content.js אוכף שמצב "xenforo" פועל רק בעמודי נושא אמיתיים
// (כתובת עם "/threads/"), כדי לא לרוץ בטעות על עמודי רשימת פורום/דף
// הבית שמלאים במודעות ותצוגות מקדימות.
function progSite() {
return {
id: "prog",
name: "פרוג",
match: "prog.co.il", // נבדק מול שם הדומיין
mode: "xenforo", // "xenforo" | "auto" | "manual"
contentSelector: "",
nextSelector: "",
prevSelector: "",
enabled: true
};
}
// אתחול/מיגרציה: מבטיח שקיימות הגדרות, ושפרוג מוגדר במצב xenforo.
async function ensureDefaults() {
const stored = await store.get(["settings", "sites"]);
const toSet = {};
if (!stored.settings) {
toSet.settings = DEFAULT_SETTINGS;
}
let sites = stored.sites;
if (!sites || !sites.length) {
sites = [progSite()];
toSet.sites = sites;
} else {
// מיגרציה: אם פרוג קיים אך לא במצב xenforo — מתקנים אותו.
let changed = false;
let hasProg = false;
sites = sites.map((s) => {
if ((s.match || "").toLowerCase().includes("prog")) {
hasProg = true;
if (s.mode !== "xenforo") { changed = true; return Object.assign({}, s, { mode: "xenforo" }); }
}
return s;
});
if (!hasProg) { sites = [progSite(), ...sites]; changed = true; }
if (changed) toSet.sites = sites;
}
if (Object.keys(toSet).length) {
await store.set(toSet);
}
}
// ---------------------------------------------------------------------------
// המנוע (הועתק מ-content.js של התוסף, עם התאמות הסקריפט בלבד)
// ---------------------------------------------------------------------------
// מילות מפתח לזיהוי אוטומטי של קישורי "הבא" ו"הקודם" (עברית + אנגלית).
const NEXT_WORDS = [
"עמוד הבא", "לעמוד הבא", "הדף הבא", "לדף הבא", "העמוד הבא",
"הבא", "קדימה", "next", "older", "ישן יותר", "המשך",
"›", "»", "→", ">>"
];
const PREV_WORDS = [
"עמוד קודם", "לעמוד הקודם", "הדף הקודם", "לדף הקודם", "העמוד הקודם",
"הקודם", "אחורה", "prev", "previous", "newer", "חדש יותר",
"‹", "«", "←", "<<"
];
// ---------------------------------------------------------------------------
// שירותי עזר
// ---------------------------------------------------------------------------
function scroller() {
return document.scrollingElement || document.documentElement;
}
// המרת URL יחסי לכתובת מלאה מול בסיס נתון.
function absolutize(url, base) {
try { return new URL(url, base).href; } catch (e) { return null; }
}
// בדיקה אם טקסט הקישור מכיל אחת ממילות המפתח.
function matchesWords(text, words) {
const t = (text || "").trim().toLowerCase();
if (!t) return false;
return words.some((w) => t.includes(w.toLowerCase()));
}
// חיפוש קישור לפי מילות מפתח בתוך מסמך.
function findLinkByWords(doc, words) {
const anchors = Array.from(doc.querySelectorAll("a[href]"));
for (const a of anchors) {
const label = (a.textContent || "") + " " +
(a.getAttribute("title") || "") + " " +
(a.getAttribute("aria-label") || "");
if (matchesWords(label, words)) return a.getAttribute("href");
}
return null;
}
// ניסיון להסיק את כתובת העמוד הבא/הקודם מתוך מספר עמוד בכתובת ה-URL.
// מזהה תבניות כמו page=3, /page/3, p=3, /3 בסוף הנתיב.
function urlByPageDelta(urlStr, delta) {
let url;
try { url = new URL(urlStr); } catch (e) { return null; }
// 1) פרמטרים נפוצים ב-query string
const params = ["page", "p", "pg", "paged"];
for (const key of params) {
if (url.searchParams.has(key)) {
const n = parseInt(url.searchParams.get(key), 10);
if (!isNaN(n)) {
const next = n + delta;
if (next < 1) return null;
url.searchParams.set(key, String(next));
return url.href;
}
}
}
// 2) תבנית /page/3 או /page-3 בנתיב (XenForo משתמש ב-/page-3)
let m = url.pathname.match(/(\/page[\/-])(\d+)/i);
if (m) {
const next = parseInt(m[2], 10) + delta;
if (next < 1) return null;
url.pathname = url.pathname.replace(m[0], m[1] + next);
return url.href;
}
// הערה: בכוונה איננו מנחשים "מספר כלשהו בסוף הכתובת", כי לרוב זהו
// מזהה נושא/מאמר ולא מספר עמוד — ניחוש כזה טוען עמודים לא קיימים.
return null;
}
// ---------------------------------------------------------------------------
// זיהוי אלמנט התוכן וכתובות הבא/הקודם (לפי מצב auto או manual)
// ---------------------------------------------------------------------------
// סלקטורים מדויקים למבנה עמוד "נושא" (Thread) בפרוג — מנוע XenForo 2.
// הסקריפט פועל *רק* על עמודי נושא (כתובת שמכילה "/threads/"), ולא על
// עמודי רשימת פורום/דף הבית — שם יש הרבה מודעות ותצוגות מקדימות
// שאינן פוסטים אמיתיים (ראו בדיקת המסלול ב-boot()).
const XF = {
// מיכל ההודעות בעמוד נושא. ההודעות עצמן (<article class="message ...">)
// הן ילדים ישירים שלו, וזה מה שמאפשר לסנן החוצה מודעות/ווידג'טים
// שמוזרקים בין ההודעות ואינם <article class="message">.
threadContent: ".block-body.js-replyNewMessageContainer",
// כל הודעה בודדת (פוסט) בתוך הנושא.
post: "article.message",
// ניווט עמודים של פרוג (XenForo pageNav).
next: "a.pageNav-jump--next",
prev: "a.pageNav-jump--prev",
pageCurrent: ".pageNav-page--current",
pageLinks: ".pageNav-page a"
};
// בדיקה אם כתובת נתונה היא עמוד "נושא" אמיתי בפרוג (ולא רשימת פורום/דף הבית).
function isProgThreadUrl(urlStr) {
try {
return /\/threads\//i.test(new URL(urlStr).pathname);
} catch (e) {
return false;
}
}
// חילוץ מספר העמוד האמיתי (ומספר העמודים הכולל, אם ידוע) מתוך עמוד XenForo.
// מבוסס על סימון העמוד הנוכחי בניווט העמודים (pageNav), ולא על מונה פנימי —
// כך שהמספר שמוצג תמיד תואם את מספר העמוד האמיתי באתר.
function getXfPageInfo(doc, urlStr) {
let page = NaN;
const curEl = doc.querySelector(XF.pageCurrent);
if (curEl) page = parseInt((curEl.textContent || "").trim(), 10);
if (isNaN(page)) {
const m = (urlStr || "").match(/\/page-(\d+)/i);
page = m ? parseInt(m[1], 10) : 1; // בפרוג, לעמוד הראשון אין סיומת page-
}
let total = 0;
doc.querySelectorAll(XF.pageLinks).forEach((a) => {
const n = parseInt((a.textContent || "").trim(), 10);
if (!isNaN(n) && n > total) total = n;
});
if (!total || total < page) total = page;
return { page, total };
}
// שליפת רשימת ההודעות (הפוסטים) בלבד מעמוד נושא, בלי מודעות/ווידג'טים
// שמוזרקים בין ההודעות (למשל "נושאים דומים", מודעות בין-הודעות וכו').
function getXfPostNodes(doc) {
const container = doc.querySelector(XF.threadContent);
if (!container) return null;
let posts = Array.from(container.children).filter((el) =>
el.tagName === "ARTICLE" && el.classList.contains("message")
);
if (!posts.length) {
// חלופה זהירה למקרה של תבנית עם עטיפה נוספת סביב הפוסטים.
posts = Array.from(container.querySelectorAll(XF.post));
}
return posts.length ? posts : null;
}
// בחירת האלמנט שמכיל את התוכן הראשי בעמוד.
function getContentElement(doc, cfg) {
// מצב XenForo: מיכל ההודעות של עמוד הנושא בלבד (לא עמודי רשימה/מודעות).
if (cfg.mode === "xenforo") {
return doc.querySelector(XF.threadContent) || null;
}
if (cfg.mode === "manual" && cfg.contentSelector) {
return doc.querySelector(cfg.contentSelector);
}
// זיהוי אוטומטי לפי סלקטורים נפוצים.
const candidates = [
"main", "article", "[role='main']",
"#content", ".content", "#main", ".main",
"#post", ".post", ".entry-content", ".article-body",
".page-content", "#primary"
];
for (const sel of candidates) {
const el = doc.querySelector(sel);
if (el && el.textContent && el.textContent.trim().length > 80) return el;
}
// fallback: הבלוק עם הכי הרבה טקסט מבין ילדי ה-body.
let best = null, bestLen = 0;
const children = doc.body ? Array.from(doc.body.children) : [];
for (const c of children) {
const tag = c.tagName.toLowerCase();
if (["header", "footer", "nav", "script", "style", "aside"].includes(tag)) continue;
const len = (c.textContent || "").trim().length;
if (len > bestLen) { bestLen = len; best = c; }
}
return best || doc.body;
}
// קבלת כתובת העמוד הבא מתוך מסמך.
function getNextUrl(doc, baseUrl, cfg) {
let href = null;
if (cfg.mode === "xenforo") {
const el = doc.querySelector(XF.next) ||
doc.querySelector('a[rel~="next"]') ||
doc.querySelector('link[rel~="next"]');
href = el ? el.getAttribute("href") : null;
if (!href) href = findLinkByWords(doc, NEXT_WORDS);
} else if (cfg.mode === "manual" && cfg.nextSelector) {
const el = doc.querySelector(cfg.nextSelector);
if (el) href = el.getAttribute("href") || el.dataset.href;
} else {
const rel = doc.querySelector('a[rel~="next"], link[rel~="next"]');
if (rel) href = rel.getAttribute("href");
if (!href) href = findLinkByWords(doc, NEXT_WORDS);
if (!href) href = urlByPageDelta(baseUrl, +1); // ייגזר יחסית לבסיס למטה
}
if (!href) return null;
// אם href כבר מלא נשאיר, אחרת נהפוך למלא מול baseUrl
if (/^https?:\/\//i.test(href)) return href;
return absolutize(href, baseUrl);
}
// קבלת כתובת העמוד הקודם מתוך מסמך.
function getPrevUrl(doc, baseUrl, cfg) {
let href = null;
if (cfg.mode === "xenforo") {
const el = doc.querySelector(XF.prev) ||
doc.querySelector('a[rel~="prev"]') ||
doc.querySelector('link[rel~="prev"]');
href = el ? el.getAttribute("href") : null;
} else if (cfg.mode === "manual" && cfg.prevSelector) {
const el = doc.querySelector(cfg.prevSelector);
if (el) href = el.getAttribute("href") || el.dataset.href;
} else {
const rel = doc.querySelector('a[rel~="prev"], link[rel~="prev"]');
if (rel) href = rel.getAttribute("href");
if (!href) href = findLinkByWords(doc, PREV_WORDS);
if (!href) href = urlByPageDelta(baseUrl, -1);
}
if (!href) return null;
if (/^https?:\/\//i.test(href)) return href;
return absolutize(href, baseUrl);
}
// ---------------------------------------------------------------------------
// המנוע הראשי
// ---------------------------------------------------------------------------
class ContinuousReader {
constructor(cfg, settings) {
this.cfg = cfg;
this.settings = settings;
this.direction = settings.direction || "both";
this.contentEl = null; // אלמנט התוכן המקורי
this.firstEl = null; // הבלוק העליון ביותר בשרשרת (לטעינה אחורה)
this.lastEl = null; // הבלוקון ביותר בשרשרת (לטעינה קדימה)
this.lastMessageEl = null; // ההודעה האחרונה בפועל בעמוד הנוכחי (לטריגר מדויק)
this.firstMessageEl = null; // ההודעה הראשונה בפועל בעמוד הנוכחי (לטריגר מדויק אחורה)
this.forwardUrl = null;
this.backwardUrl = null;
this.visited = new Set([location.href]);
this.loadingF = false;
this.loadingB = false;
// טיימרים של מעבר עמוד מתוזמן (ראו _scheduleLoad) — null כשאין מעבר ממתין.
this._forwardLoadTimer = null;
this._backwardLoadTimer = null;
// מספרי העמוד המוצגים תמיד נלקחים מתוך העמוד עצמו (pageNav של פרוג),
// ולא ממונה מקומי — כדי שהתווית תציג את מספר העמוד האמיתי באתר.
this.pageForward = 1;
this.pageBackward = 1;
this.pageTotal = 0;
this.indicator = null;
// עוטף כדי שאירוע הגלילה לא יעביר את ה-Event כ-forwardOnly.
this._scrollHandler = () => this._onScroll(false);
// רשימת כל העמודים שנטעלו עד כה (כולל העמוד המקורי), מסודרת לפי
// סדר הופעתם במסמך (מלמעלה למטה = ממספר עמוד קטן לגדול). כל איבר:
// { page, url, el }. משמשת כדי לדעת בכל רגע איזה עמוד "בתצוגה"
// ולעדכן את שורת הכתובת בהתאם (ראו _updateAddressBar).
this.sections = [];
this._lastUrlSynced = location.href;
// "טעינה מוקדמת" (prefetch): מביאים את העמוד הבא/הקודם ברקע עוד
// לפני שמגיעים לסף הגלילה, כך שברגע שצריך להציג אותו זה כמעט מיידי —
// בקצב הגלילה עצמה, בלי המתנה לרשת.
this._prefetchCache = { forward: null, backward: null };
// כל בקשות ה-fetch שכרגע "באוויר" (כולל טעינות מוקדמות ברקע) -
// כדי שנוכל לבטל אותן בצורה נקייה אם הדף ננטש (ראו _abortAllFetches).
this._activeFetchControllers = new Set();
}
async init() {
this.contentEl = getContentElement(document, this.cfg);
if (!this.contentEl || !this.contentEl.parentNode) {
console.warn("[גלילה רציפה] לא נמצא אלמנט תוכן מתאים בעמוד.");
return;
}
this.firstEl = this.contentEl;
this.lastMessageEl = this._lastMessageIn(this.contentEl);
this.firstMessageEl = this._firstMessageIn(this.contentEl);
// עוגן ההוספה לטעינה קדימה: אם יש הודעה אחרונה מזוהה בתוך מיכל
// התוכן (כמו בפרוג/XenForo), מוסיפים את העמוד הבא ישר אחריה — ולא
// אחרי כל מיכל התוכן, שעלול לכלול גם תיבת תגובה/ווידג'טים אחרי
// ההודעה האחרונה. בלי זה, בטעינה הקדימה הראשונה התגית עם מספר
// העמוד "נקברת" הרחק אחרי כל אותו תוכן נוסף ולא נראית כשמגיעים
// לסוף ההודעות (למרות שהטריגר נבדק לפי ההודעה האחרונה עצמה).
this.lastEl = (this.lastMessageEl && this.lastMessageEl.parentNode)
? this.lastMessageEl
: this.contentEl;
this.forwardUrl = getNextUrl(document, location.href, this.cfg);
this.backwardUrl = getPrevUrl(document, location.href, this.cfg);
if (this.cfg.mode === "xenforo") {
const info = getXfPageInfo(document, location.href);
this.pageForward = info.page;
this.pageBackward = info.page;
this.pageTotal = info.total;
}
// רישום העמוד המקורי (זה שממנו האתחול קרה) ברשימת העמודים שנטעלו.
this.sections.push({
page: this.cfg.mode === "xenforo" ? this.pageForward : 1,
url: location.href,
el: this.contentEl
});
console.log("[גלילה רציפה] אותחל.",
"מצב:", this.cfg.mode,
"| תוכן:", this.contentEl && (this.contentEl.className || this.contentEl.tagName),
"| עמוד הבא:", this.forwardUrl || "לא נמצא",
"| עמוד קודם:", this.backwardUrl || "לא נמצא",
"| כיוון:", this.direction);
if (!this.forwardUrl && !this.backwardUrl) {
console.warn("[גלילה רציפה] לא נמצאו קישורי הבא/קודם בעמוד הזה — אין מה לטעון.");
}
if (this.settings.showIndicator) this._createIndicator();
this._updateIndicator("מוכן");
// התחלת טעינה מוקדמת ברקע כבר מהאתחול.
this._prefetch("forward");
this._prefetch("backward");
window.addEventListener("scroll", this._scrollHandler, { passive: true });
// מבטלים בקשות רשת שעדיין באוויר (בעיקר טעינות מוקדמות ברקע) כשעוזבים
// את הדף - כדי לא להשאיר Promise שנדחה על "Failed to fetch" סתם כי
// הדף כבר לא קיים. pagehide נורה גם בניווט רגיל וגם בסגירת טאב.
this._onPageHide = () => this._abortAllFetches();
window.addEventListener("pagehide", this._onPageHide);
// בדיקה ראשונית (למקרה שהעמוד קצר מהמסך) — קדימה בלבד.
setTimeout(() => this._onScroll(true), 300);
}
// מציאת ההודעה (הפוסט) האחרונה בפועל בתוך מיכל נתון — כדי לדעת בדיוק
// מתי הקורא "הגיע" אליה, ולא לפי מרחק בפיקסלים מתחתית המיכל כולו
// (שעלולה לכלול פוטר, תיבת תגובה, "נושאים דומים" וכו').
_lastMessageIn(container) {
if (!container) return null;
if (this.cfg.mode === "xenforo") {
const posts = container.querySelectorAll(XF.post);
if (posts.length) return posts[posts.length - 1];
}
return container;
}
// מציאת ההודעה (הפוסט) הראשונה בפועל בתוך מיכל נתון — משמש לטריגר
// המדויק של הטעינה אחורה (רק כשמגיעים בפועל להודעה הראשונה בעמוד).
_firstMessageIn(container) {
if (!container) return null;
if (this.cfg.mode === "xenforo") {
const posts = container.querySelectorAll(XF.post);
if (posts.length) return posts[0];
}
return container;
}
// "נועלים" זמנית גלילה ידנית (גלגלת עכבר / מגע) למשך אנימציית הנחיתה.
// בלי זה, אם המשתמש ממשיך לגלול בפועל (למשל תנופה שנמשכת אחרי החלקה
// בטראצפד) בזמן שה-JS מנסה לגלול לעמוד/לתחתית ה-section, שתי הגלילות
// "נלחמות" זו בזו — ובגלילה קדימה (שבניגוד לאחורה לא נעצרת טבעית בקצה
// המסמך) זה גורם לכך שהנחיתה כמעט ולא מורגשת. הנעילה משתחררת אוטומטית
// ברגע שהאנימציה מסתיימת, כך שהיא לא פוגעת בגלילה רגילה בשאר הזמן.
_lockNativeScroll() {
this._scrollLockCount = (this._scrollLockCount || 0) + 1;
if (this._scrollLockCount > 1) return;
const prevent = (e) => e.preventDefault();
this._scrollLockPrevent = prevent;
window.addEventListener("wheel", prevent, { passive: false });
window.addEventListener("touchmove", prevent, { passive: false });
}
_unlockNativeScroll() {
this._scrollLockCount = Math.max(0, (this._scrollLockCount || 1) - 1);
if (this._scrollLockCount > 0) return;
if (this._scrollLockPrevent) {
window.removeEventListener("wheel", this._scrollLockPrevent);
window.removeEventListener("touchmove", this._scrollLockPrevent);
this._scrollLockPrevent = null;
}
}
// גלילה עדינה אל יעד - אבל בניגוד לגרסה קודמת, היעד לא מחושב פעם אחת
// מראש אלא *מחדש בכל פריים* (getTargetY היא פונקציה, לא מספר קבוע).
//
// למה זה חשוב: כשנוחתים על "ראש" עמוד חדש שנטען, אם בזמן האנימציה
// (700 מ"ש) יש שינוי בגובה הדף - למשל תמונה/אווטאר שמשלימים טעינה
// ודוחפים תוכן למטה, או תנופת גלילה טבעית (טראצקפד/מגע) שממשיכה
// למרות הנעילה, או אפילו scroll-behavior:smooth שהאתר מגדיר - אז יעד
// קבוע שחושב פעם אחת "מתיישן" עד שמגיעים אליו, וכתוצאה נוחתים במקום
// הלא נכון (למשל עמוק בתוך הפוסט הבא, אחרי הכותרת שלו, במקום על
// התגית). הפתרון: בכל פריים מחשבים מחדש איפה היעד *באמת* נמצא כרגע,
// ומתקדמים חלק לכיוונו (כמו "מכ״ם רודף") - כך שגם אם התוכן זז תוך
// כדי, מתכנסים למקום הנכון בפועל ולא למקום שהיה נכון שנייה קודם.
_smoothScrollTo(getTargetY, duration) {
const targetFn = typeof getTargetY === "function" ? getTargetY : () => getTargetY;
const dur = duration || 700;
const startTime = performance.now();
let settledFrames = 0;
this._lockNativeScroll();
const step = (now) => {
const t = (now - startTime) / dur;
const currentY = window.scrollY || document.documentElement.scrollTop;
const liveTarget = targetFn();
const gap = liveTarget - currentY;
// מקדם ההתקדמות: מתחיל עדין (לא קפיצה), ומתחזק ככל שמתקדמים -
// כך שגם אם היעד "זז" באמצע, מתכנסים אליו בפועל ולא נתקעים.
const speed = Math.min(0.35, 0.06 + t * 0.5);
let nextY;
if (t >= 1 || Math.abs(gap) < 1.5) {
nextY = liveTarget; // תמיד "נצמדים" בדיוק ליעד העדכני בסוף.
} else {
nextY = currentY + gap * speed;
}
// behavior: "auto" מפורש בכוונה, כדי לבטל scroll-behavior:smooth
// שהאתר עצמו אולי מגדיר ב-CSS - אחרת קריאה "סתמית" הייתה מופעלת
// כאנימציה חלקה נוספת של הדפדפן, שמצטברת עם הלולאה שלנו כאן
// וגורמת להחמצת יעד (overshoot).
window.scrollTo({ top: Math.max(0, nextY), left: 0, behavior: "auto" });
if (Math.abs(liveTarget - nextY) < 1.5) {
settledFrames++;
} else {
settledFrames = 0;
}
// ממשיכים עד שממש מתייצבים על היעד העדכני (כמה פריימים רצוף),
// לא רק עד שחלף הזמן - כך שאם היעד המשיך לזוז, ממשיכים לרדוף.
if (settledFrames < 3 && (now - startTime) < dur + 900) {
requestAnimationFrame(step);
} else {
this._unlockNativeScroll();
}
};
requestAnimationFrame(step);
}
// ממתין שהגלילה "תירגע" (אין יותר שינוי מורגש במיקום הגלילה) לפני
// שמפעילים קולבק - כדי לא להתחיל את אנימציית הנחיתה בדיוק באמצע תנופה
// פעילה של המשתמש, מה שהיה גורם לשתי הגלילות "להיאבק" זו בזו. יש
// תקרת זמן (maxWait) כדי שלא ניתקע לנצח אם הדפדפן מדווח על תזוזה זעירה
// מתמשכת.
_waitForScrollIdle(callback, maxWait, idleFrames) {
const start = performance.now();
const needed = idleFrames || 4;
const cap = maxWait || 1500;
let lastY = window.scrollY || document.documentElement.scrollTop;
let stable = 0;
const check = () => {
const y = window.scrollY || document.documentElement.scrollTop;
if (Math.abs(y - lastY) < 0.5) {
stable++;
} else {
stable = 0;
lastY = y;
}
if (stable >= needed || (performance.now() - start) > cap) {
callback();
return;
}
requestAnimationFrame(check);
};
requestAnimationFrame(check);
}
// גלילה לראש ה-section החדש, כך שהתגית עם מספר העמוד תיראה מיד בראש
// המסך — בעדינות ולא בקפיצה חדה. היעד מחושב מחדש בכל פריים (ראו
// _smoothScrollTo) כדי להתמודד עם תוכן שזז תוך כדי (תמונות שנטענות
// וכו').
_scrollToSectionTop(section) {
this._smoothScrollTo(() => {
const rect = section.getBoundingClientRect();
return Math.max(0, rect.top + (window.scrollY || document.documentElement.scrollTop) - 6);
}, 700);
}
// גלילה לתחתית ה-section החדש (בשימוש בטעינה אחורה): "נוחתים" על סוף
// העמוד שנטען, כך שהתגית עם מספר העמוד — שבטעינה אחורה נמצאתית
// ה-section, בדיוק בנקודת המעבר לתוכן הישן — תיראה מיד למטה במסך.
// גם כאן היעד מחושב מחדש בכל פריים.
_scrollToSectionBottom(section) {
this._smoothScrollTo(() => {
const rect = section.getBoundingClientRect();
const currentY = window.scrollY || document.documentElement.scrollTop;
const vh = window.innerHeight;
return Math.max(0, rect.bottom + currentY - vh + 10);
}, 700);
}
setDirection(dir) {
this.direction = dir;
this._updateIndicator(this._dirLabel());
if (dir === "off") {
this._cancelScheduledLoad("forward");
this._cancelScheduledLoad("backward");
}
// בדיקה מיידית אם צריך לטעון עכשיו.
this._onScroll();
}
_dirLabel() {
switch (this.direction) {
case "both": return "פעיל: קדימה + אחורה";
case "forward": return "פעיל: קדימה";
case "backward": return "פעיל: אחורה";
default: return "כבוי";
}
}
_forwardEnabled() {
return this.direction === "both" || this.direction === "forward";
}
_backwardEnabled() {
return this.direction === "both" || this.direction === "backward";
}
// forwardOnly=true בבדיקה הראשונית, כדי לא לטעון אחורה אוטומטית בעת הטעינה.
_onScroll(forwardOnly) {
if (this.direction === "off") return;
const vh = window.innerHeight;
// קדימה: הטריגר מבוסס על ההודעה האחרונה בפועל בעמוד — מגיעים אליה
// רק ברגע שהיא נכנסת לתצוגה, לא לפני. הטעינה המוקדמת (prefetch)
// כבר דואגת שהעמוד הבא יהיה מוכן ברקע. עם זאת, לא טוענים באופן
// מיידי — ראו _scheduleLoad.
if (this._forwardEnabled() && this.forwardUrl && !this.loadingF && this.lastMessageEl) {
const rect = this.lastMessageEl.getBoundingClientRect();
if (rect.top <= vh) {
this._scheduleLoad("forward");
} else {
this._cancelScheduledLoad("forward");
}
} else {
this._cancelScheduledLoad("forward");
}
// אחורה: לא לפי מרחק פיקסלים מראש העמוד, אלא בדיוק כמו קדימה —
// רק ברגע שההודעה הראשונה בפועל בעמוד נכנסת לתצוגה (מגיעים אליה
// ממש). כך הטעינה אחורה מתרחשת אך ורק כשמגיעים לתגובה הראשונה,
// בדיוק כמו שהטעינה קדימה מתרחשת רק כשמגיעים לתגובה האחרונה.
if (!forwardOnly && this._backwardEnabled() && this.backwardUrl && !this.loadingB && this.firstMessageEl) {
const rect = this.firstMessageEl.getBoundingClientRect();
if (rect.bottom >= 0) {
this._scheduleLoad("backward");
} else {
this._cancelScheduledLoad("backward");
}
} else if (!forwardOnly) {
this._cancelScheduledLoad("backward");
}
// עדכון שורת הכתובת לפי העמוד שבתצוגה כרגע.
this._updateAddressBar();
}
// מתזמן מעבר עמוד אוטומטי בעוד כשנייה וחצי, במקום לבצע אותו באופן
// מיידי ברגע שמגיעים לסוף/תחילת העמוד. שתי סיבות לזה:
// 1) נותן למשתמש חלון זמן להעביר עמוד *ידנית* בעצמו (למשל בלחיצה על
// קישור "עמוד הבא" באתר) אם הוא מעדיף, במקום שהסקריפט "יחטוף" לו
// את הגלילה.
// 2) עד שהטיימר מסתיים, תנופת הגלילה של המשתמש (למשל גלילה בטראצקפד)
// כבר נרגעה ונעצרה — כך שכשבאמת מתבצעת ההוספה והנחיתה על העמוד
// החדש, אין גלילה ידנית מתחרה שמבטלת את התחושה של הנחיתה.
// אם המשתמש מתרחק בינתיים מסף הטעינה (גולל בחזרה) — הטיימר מבוטל.
_scheduleLoad(direction) {
const key = direction === "forward" ? "_forwardLoadTimer" : "_backwardLoadTimer";
if (this[key]) return; // כבר מתוזמן
this._updateIndicator(
direction === "forward" ? "עובר לעמוד הבא בעוד רגע… (אפשר גם ידנית)"
: "עובר לעמוד הקודם בעוד רגע… (אפשר גם ידנית)",
2200
);
this[key] = setTimeout(() => {
this[key] = null;
if (direction === "forward") this._loadNext();
else this._loadPrev();
}, 2000);
}
_cancelScheduledLoad(direction) {
const key = direction === "forward" ? "_forwardLoadTimer" : "_backwardLoadTimer";
if (this[key]) {
clearTimeout(this[key]);
this[key] = null;
}
}
// מביא ומפרסר מסמך מכתובת נתונה. משתמש ב-AbortController כדי שאם
// הדף ננטש (המשתמש עוזב/סוגר את הטאב) תוך כדי טעינה מוקדמת ברקע,
// הבקשה תבוטל בצורה נקייה במקום להיכשל עם "Failed to fetch".
async _fetchDoc(url) {
const controller = new AbortController();
this._activeFetchControllers.add(controller);
try {
const res = await fetch(url, { credentials: "include", signal: controller.signal });
if (!res.ok) throw new Error("HTTP " + res.status);
const html = await res.text();
const doc = new DOMParser().parseFromString(html, "text/html");
return doc;
} finally {
this._activeFetchControllers.delete(controller);
}
}
// מבטל את כל הבקשות שעדיין באוויר (למשל טעינות מוקדמות ברקע). נקרא
// כשהדף ננטש, כדי לא להשאיר בקשות תלויות שנכשלות אחרי שכבר לא רלוונטי.
_abortAllFetches() {
this._activeFetchControllers.forEach((c) => {
try { c.abort(); } catch (e) { /* מתעלמים */ }
});
this._activeFetchControllers.clear();
}
// מתחיל הבאה ברקע (ללא await) של עמוד ה-URL הנוכחי בכיוון המבוקש,
// ושומר את ה-Promise במטמון קטן לפי כיוון. כך כשמגיעים לסף הגלילה
// בפועל, הטעינה כבר כמעט הושלמה ותוצג כמעט מיידית.
_prefetch(direction) {
const url = direction === "forward" ? this.forwardUrl : this.backwardUrl;
if (!url) return;
const cache = this._prefetchCache[direction];
if (cache && cache.url === url) return; // כבר בטעינה/הובא בעבר עבור אותו URL
const promise = this._fetchDoc(url).catch((e) => {
// אם נכשל, מנקים את המטמון כדי שניסיון אמיתי בהמשך יחזור לנסות ברשת ישירות.
if (this._prefetchCache[direction] && this._prefetchCache[direction].url === url) {
this._prefetchCache[direction] = null;
}
throw e;
});
// חשוב: מוסיפים כאן ענף-צל שקט שתופס דחייה (reject) גם אם בסוף אף
// אחד לא צורך את ה-Promise הזה בפועל (למשל אם המשתמש עוזב את הדף,
// מכבה את הכיוון, או שהטעינה המוקדמת נכשלת בלי שמגיעים לסף
// שמפעיל _getDoc). בלי זה, הדפדפן מדווח "Uncaught (in promise)"
// בקונסולה על טעינות רקע כושלות שבכלל לא נחוצות היו בסוף - זה לא
// באמת באג פונקציונלי, רק "רעש" בלוג. הענף המקורי (cache.promise)
// ממשיך להידחות כרגיל עבור מי שבאמת מחכה לו ב-_getDoc.
promise.catch(() => {});
this._prefetchCache[direction] = { url, promise };
}
// מחזיר את המסמך של ה-URL המבוקש: מהמטמון אם כבר מוכן/בדרך, אחרת מביא ישירות.
async _getDoc(direction, url) {
const cache = this._prefetchCache[direction];
if (cache && cache.url === url) {
this._prefetchCache[direction] = null;
try {
return await cache.promise;
} catch (e) {
// נופל חזרה לניסיון ישיר אם הטעינה המוקדמת נכשלה.
}
}
return this._fetchDoc(url);
}
// סלקטורים למודעות/ווידג'טים שעלולים להישאר בתוך הודעה בודדת (הגנה נוספת,
// מעבר לכך שכבר בוחרים רק <article class="message"> ולא אלמנטים אחרים).
static get AD_SELECTORS() {
return "ins.adsbygoogle, iframe[src*='ads'], iframe[src*='doubleclick'], " +
"[class*='adUnit' i], [id*='google_ads' i], .p-body-pageContent-ad, .js-ad";
}
// בניית section חדש מתוך רשימת הודעות (posts) שהובאו מעמוד אחר.
// sourceNodes: מערך של אלמנטי <article class="message"> בלבד (ללא מודעות),
// או, במצב auto/manual, מערך עם אלמנט תוכן בודד.
_buildSection(sourceNodes, baseUrl, pageInfo, dir) {
const wrapper = document.createElement("div");
wrapper.className = "cr-page-section";
wrapper.setAttribute("data-cr-page", pageInfo.page);
// מחוון מפריד בין עמודים — עיצוב ברור (תגית + קו), מציג את מספר
// העמוד האמיתי באתר.
const sep = document.createElement("div");
sep.className = "cr-page-separator";
const badge = document.createElement("span");
badge.className = "cr-page-badge";
const icon = document.createElement("span");
icon.className = "cr-page-badge-icon";
icon.textContent = "📄";
const label = document.createElement("span");
label.textContent = pageInfo.total && pageInfo.total > pageInfo.page
? "עמוד " + pageInfo.page + " מתוך " + pageInfo.total
: "עמוד " + pageInfo.page;
badge.appendChild(icon);
badge.appendChild(label);
sep.appendChild(badge);
const nodes = Array.isArray(sourceNodes) ? sourceNodes : [sourceNodes];
const imported = nodes.map((node) => {
const clone = document.importNode(node, true);
// הסרת סקריפטים כדי למנוע הרצה כפולה/שגיאות.
clone.querySelectorAll("script").forEach((s) => s.remove());
// הגנה נוספת: הסרת שאריות מודעות/ווידג'טים שעלולות להיות מוטמעות
// בתוך הודעה בודדת (גם אם ברוב המקרים כבר לא נבחרו כלל).
clone.querySelectorAll(ContinuousReader.AD_SELECTORS).forEach((el) => el.remove());
// המרת כתובות יחסיות למלאות מול העמוד שממנו נלקח התוכן.
this._absolutizeTree(clone, baseUrl);
return clone;
});
if (dir === "prev") {
// עמוד קודם (טעינה אחורה): מציבים את התגית *אחרי* ההודעות, כלומר
//ית ה-section — בדיוק בנקודת התפר מול התוכן הישן שכבר היה
// בעמוד. כך כשגוללים אחורה "נוחתים" על סוף העמוד החדש שנטען,
// עם תגית מספר העמוד נראית למטה במסך (ולא צריך לגלול עוד כדי
// לראות אותה בראש כל ההודעות של אותו עמוד).
imported.forEach((node) => wrapper.appendChild(node));
wrapper.appendChild(sep);
} else {
// עמוד הבא (טעינה קדימה): התגית בראש ה-section, כך שרואים אותה
// מיד כשמגיעים אליה ואז ממשיכים לקרוא את העמוד מלמעלה למטה.
wrapper.appendChild(sep);
imported.forEach((node) => wrapper.appendChild(node));
}
return wrapper;
}
_absolutizeTree(root, baseUrl) {
const fix = (el, attr) => {
const v = el.getAttribute(attr);
if (v && !/^(https?:|data:|mailto:|tel:|#|javascript:)/i.test(v)) {
const abs = absolutize(v, baseUrl);
if (abs) el.setAttribute(attr, abs);
}
};
root.querySelectorAll("[href]").forEach((el) => fix(el, "href"));
root.querySelectorAll("[src]").forEach((el) => fix(el, "src"));
root.querySelectorAll("img[srcset], source[srcset]").forEach((el) => {
const val = el.getAttribute("srcset");
if (!val) return;
const fixed = val.split(",").map((part) => {
const seg = part.trim().split(/\s+/);
const abs = absolutize(seg[0], baseUrl);
if (abs) seg[0] = abs;
return seg.join(" ");
}).join(", ");
el.setAttribute("srcset", fixed);
});
}
async _loadNext() {
if (this.loadingF || !this.forwardUrl) return;
if (this.visited.has(this.forwardUrl)) { this.forwardUrl = null; return; }
this.loadingF = true;
const url = this.forwardUrl;
this.visited.add(url);
this._updateIndicator("טוען עמוד הבא…");
console.log("[גלילה רציפה] טוען קדימה:", url);
try {
// בטיחות: אם ה-URL הבא כבר לא עמוד "נושא" אמיתי בפרוג (למשל הופנה
// לרשימת פורום או לדף אחר) — לא ממשיכים, כדי לא לגרור מודעות/תוכן זר.
if (this.cfg.mode === "xenforo" && !isProgThreadUrl(url)) {
this.forwardUrl = null;
this._updateIndicator("סוף התוכן");
return;
}
const doc = await this._getDoc("forward", url);
const nodes = this.cfg.mode === "xenforo"
? getXfPostNodes(doc)
: getContentElement(doc, this.cfg);
if (nodes) {
const pageInfo = this.cfg.mode === "xenforo"
? getXfPageInfo(doc, url)
: { page: ++this.pageForward, total: 0 };
if (this.cfg.mode === "xenforo") { this.pageForward = pageInfo.page; this.pageTotal = pageInfo.total; }
const section = this._buildSection(nodes, url, pageInfo, "next");
this.lastEl.parentNode.insertBefore(section, this.lastEl.nextSibling);
this.lastEl = section;
this.lastMessageEl = this._lastMessageIn(section);
this.sections.push({ page: pageInfo.page, url, el: section });
this.forwardUrl = getNextUrl(doc, url, this.cfg);
this._updateIndicator(this._dirLabel());
console.log("[גלילה רציפה] נוסף עמוד", pageInfo.page,
"| הבא:", this.forwardUrl || "אין (סוף)");
// התחלת טעינה מוקדמת של העמוד שאחרי, כדי שהמעבר הבא יהיה מיידי.
this._prefetch("forward");
// מעבר עדין (לא קפיצה חדה) לתחילת העמוד החדש, כך שהתגית עם
// מספר העמוד תיראה מיד בראש המסך. קודם ממתינים שתנופת גלילה
// טבעית (אם יש) תירגע, כדי שהיא לא "תילחם" באנימציית הנחיתה
// ותגרום לתגית לחלוף מבלי שרואים אותה.
this._waitForScrollIdle(() => {
this._scrollToSectionTop(section);
});
} else {
this.forwardUrl = null;
this._updateIndicator("סוף התוכן");
console.warn("[גלילה רציפה] בעמוד שנטען לא נמצא אלמנט תוכן — עוצר קדימה.");
}
} catch (e) {
console.warn("[גלילה רציפה] נכשלה טעינת העמוד הבא:", e);
this._updateIndicator("שגיאה בטעינה קדימה");
this.forwardUrl = null;
} finally {
this.loadingF = false;
}
}
async _loadPrev() {
if (this.loadingB || !this.backwardUrl) return;
if (this.visited.has(this.backwardUrl)) { this.backwardUrl = null; return; }
this.loadingB = true;
const url = this.backwardUrl;
this.visited.add(url);
this._updateIndicator("טוען עמוד קודם…");
const s = scroller();
const beforeHeight = s.scrollHeight;
const beforeTop = window.scrollY || s.scrollTop;
try {
if (this.cfg.mode === "xenforo" && !isProgThreadUrl(url)) {
this.backwardUrl = null;
this._updateIndicator("תחילת התוכן");
return;
}
const doc = await this._getDoc("backward", url);
const nodes = this.cfg.mode === "xenforo"
? getXfPostNodes(doc)
: getContentElement(doc, this.cfg);
if (nodes) {
const pageInfo = this.cfg.mode === "xenforo"
? getXfPageInfo(doc, url)
: { page: --this.pageBackward, total: 0 };
if (this.cfg.mode === "xenforo") { this.pageBackward = pageInfo.page; this.pageTotal = pageInfo.total; }
const section = this._buildSection(nodes, url, pageInfo, "prev");
this.firstEl.parentNode.insertBefore(section, this.firstEl);
this.firstEl = section;
this.firstMessageEl = this._firstMessageIn(section);
this.sections.unshift({ page: pageInfo.page, url, el: section });
this.backwardUrl = getPrevUrl(doc, url, this.cfg);
// התחלת טעינה מוקדמת של העמוד שלפני כן.
this._prefetch("backward");
// פיצוי מיידי על השינוי בגובה כדי שהגלילה לא "תקפוץ" ברגע ההוספה.
// behavior: "auto" בכוונה - זו קפיצה מיידית שאמורה להיות בלתי
// מורגשת; אם לאתר יש scroll-behavior: smooth ב-CSS, קריאה
// "סתמית" הייתה הופכת את הקפיצה המיידית הזו לאנימציה נראית
// לעין (בדיוק ההפך ממה שהיא נועדה לעשות).
requestAnimationFrame(() => {
const afterHeight = scroller().scrollHeight;
const delta = afterHeight - beforeHeight;
window.scrollTo({ top: beforeTop + delta, left: 0, behavior: "auto" });
this._updateIndicator(this._dirLabel());
// ואז מעבר עדין (לא קפיצה) אל סוף העמוד החדש שנטען — כך
// ש"נוחתים" על סופו, עם תגית מספר העמוד נראיתית המסך.
// גם כאן ממתינים לרגיעת תנופת גלילה, ליתר ביטחון.
this._waitForScrollIdle(() => {
this._scrollToSectionBottom(section);
});
});
} else {
this.backwardUrl = null;
this._updateIndicator("תחילת התוכן");
}
} catch (e) {
console.warn("[גלילה רציפה] נכשלה טעינת העמוד הקודם:", e);
this._updateIndicator("שגיאה בטעינה אחורה");
this.backwardUrl = null;
} finally {
this.loadingB = false;
}
}
// "עוקב גלילה" (scroll-spy): קובע איזה עמוד טעון נמצא כרגע בתצוגה,
// ומעדכן את שורת הכתובת של הדפדפן לכתובת האמיתית של אותו עמוד באתר —
// בלי לרענן ובלי להוסיף רשומה חדשה להיסטוריית "חזור" (replaceState).
// כך שורת הכתובת תמיד תשקף את מספר העמוד האמיתי שרואים כרגע, בדיוק
// כאילו ניווטתם אליו ישירות באתר.
_updateAddressBar() {
if (!this.sections.length) return;
// קו ייחוס קרוב לראש המסך: העמוד שתחילתו כבר עברה את הקו הזה
// נחשב "בתצוגה".
const refY = window.innerHeight * 0.25;
let current = this.sections[0];
for (const s of this.sections) {
const rect = s.el.getBoundingClientRect();
if (rect.top <= refY) {
current = s;
} else {
break; // הרשימה מסודרת לפי סדר המסמך, אז אפשר לעצור כאן.
}
}
if (!current.url || current.url === this._lastUrlSynced) return;
try {
if (new URL(current.url).origin !== location.origin) return;
} catch (e) {
return;
}
this._lastUrlSynced = current.url;
try {
history.replaceState(history.state, "", current.url);
} catch (e) {
// מתעלמים משגיאות History API (למשל אם הדפדפן חסם זאת).
}
}
// ------- חיווי צף על המסך -------
// מוצג לזמן קצר בלבד בזמן פעולה (טעינה/שינוי) ונעלם לבד, כדי שלא
// "יקפוץ" ויציק לקורא כל הזמן כשגוללים עמוק בתוך התוכן.
_createIndicator() {
const el = document.createElement("div");
el.className = "cr-indicator";
document.documentElement.appendChild(el);
this.indicator = el;
}
_updateIndicator(text, holdMs) {
if (!this.indicator) return;
this.indicator.textContent = text;
this.indicator.classList.add("cr-indicator-show");
clearTimeout(this._hideT);
this._hideT = setTimeout(() => {
this.indicator.classList.remove("cr-indicator-show");
}, holdMs || 1400);
}
}
// ---------------------------------------------------------------------------
// אתחול: התאמת האתר הנוכחי מול ההגדרות
// ---------------------------------------------------------------------------
function findSiteConfig(sites) {
const host = location.hostname.toLowerCase();
for (const site of sites) {
if (!site.enabled) continue;
const m = (site.match || "").trim().toLowerCase();
if (!m) continue;
// תמיכה בכוכביות פשוטות (glob), אחרת בדיקת "מכיל".
if (m.includes("*")) {
const re = new RegExp("^" + m.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*") + "$");
if (re.test(host)) return site;
} else if (host.includes(m)) {
return site;
}
}
return null;
}
let reader = null;
async function boot() {
let stored;
try {
stored = await store.get(["sites", "settings"]);
} catch (e) {
return; // אחסון הסקריפט לא זמין
}
const sites = stored.sites || [];
const settings = stored.settings || { direction: "both" };
const cfg = findSiteConfig(sites);
if (!cfg) return; // האתר הזה לא מוגדר בסקריפט
// שכבת ביטחון: פרוג הוא XenForo — נכפה את המצב הנכון גם אם ההגדרה ישנה.
if (/prog\.co\.il/i.test(location.hostname) && cfg.mode !== "xenforo") {
cfg.mode = "xenforo";
}
// מצב XenForo (פרוג ופורומים דומים) פועל אך ורק בעמוד "נושא" אמיתי
// (כתובת עם "/threads/"), כלומר על ההודעות עצמן. עמודי רשימת פורום,
// דף הבית וכד' מלאים במודעות ותצוגות מקדימות ואינם רלוונטיים לגלילה
// רציפה של פוסטים — לכן הסקריפט פשוט לא יתאתחל בהם.
if (cfg.mode === "xenforo" && !isProgThreadUrl(location.href)) {
return;
}
if (settings.direction === "off") {
// עדיין נאתחל כדי שאפשר יהיה להדליק מהתפריט בלי רענון.
}
reader = new ContinuousReader(cfg, settings);
await reader.init();
}
// ---------------------------------------------------------------------------
// תפריט הסקריפט — במקום הפופאפ ועמוד ההגדרות של התוסף
// ---------------------------------------------------------------------------
// שינוי כיוון גלילה (נשמר + מיושם מיד על העמוד הנוכחי, בלי רענון).
async function applyDirection(dir) {
const { settings } = await store.get(["settings"]);
const s = Object.assign({}, DEFAULT_SETTINGS, settings, { direction: dir });
await store.set({ settings: s });
if (reader) {
reader.setDirection(dir);
} else {
console.warn("[גלילה רציפה] הסקריפט לא פעיל באתר הזה — הכיוון ייכנס לתוקף באתר תומך.");
}
console.log("[גלילה רציפה] כיוון שונה ל:", dir);
}
// הוספת אתר חדש (במקום "הוספת אתרים והגדרות" בפופאפ).
async function addSitePrompt() {
const domain = await gmPrompt(
"הוספת אתר — הזינו דומיין (בדיקת \"מכיל\"), למשל: prog.co.il",
""
);
if (!domain) return;
const d = (domain || "").trim().toLowerCase();
if (!d) return;
const { sites } = await store.get(["sites"]);
const arr = Array.isArray(sites) ? sites : [];
if (arr.some((s) => (s.match || "").toLowerCase().includes(d))) {
console.warn("[גלילה רציפה] האתר כבר קיים בהגדרות.");
return;
}
arr.push({
id: "site_" + Date.now(),
name: d,
match: d,
mode: "auto",
contentSelector: "",
nextSelector: "",
prevSelector: "",
enabled: true
});
await store.set({ sites: arr });
// רענון כדי שהאתר החדש ייכנס לתוקף בעמוד הנוכחי (אם זה הוא).
location.reload();
}
// הסרת האתר שתאם לדומיין הנוכחי (אם יש כזה).
async function removeCurrentSite() {
const { sites } = await store.get(["sites"]);
const arr = Array.isArray(sites) ? sites : [];
const host = location.hostname.toLowerCase();
const remaining = arr.filter((s) => {
if (!s.enabled) return true;
const m = (s.match || "").trim().toLowerCase();
if (!m) return true;
if (m.includes("*")) {
const re = new RegExp("^" + m.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*") + "$");
return !re.test(host);
}
return !host.includes(m);
});
if (remaining.length === arr.length) {
console.warn("[גלילה רציפה] האתר הנוכחי לא מוגדר בסקריפט — אין מה להסיר.");
return;
}
await store.set({ sites: remaining });
location.reload();
}
// איפוס מלא להגדרות ברירת המחדל.
async function resetSettings() {
await store.set({ settings: DEFAULT_SETTINGS, sites: [progSite()] });
location.reload();
}
// רישום פקודות התפריט של Tampermonkey.
function registerMenu() {
const dirs = [
["both", "⇅ קדימה + אחורה"],
["forward", "↓ רק קדימה"],
["backward", "↑ רק אחורה"],
["off", "✕ כבוי"]
];
for (const [d, label] of dirs) {
GM_registerMenuCommand("מצב גלילה: " + label, () => applyDirection(d));
}
GM_registerMenuCommand("➕ הוספת אתר", () => addSitePrompt());
GM_registerMenuCommand("🗑 הסרת האתר הנוכחי", () => removeCurrentSite());
GM_registerMenuCommand("🔄 איפוס הגדרות", () => resetSettings());
}
// ---------------------------------------------------------------------------
// התחלה
// ---------------------------------------------------------------------------
(async function main() {
await ensureDefaults();
registerMenu();
await boot();
})();
})();