diff --git a/src/plugins/built-in/enhancedNavigation/index.ts b/src/plugins/built-in/enhancedNavigation/index.ts new file mode 100644 index 00000000..e16d5d30 --- /dev/null +++ b/src/plugins/built-in/enhancedNavigation/index.ts @@ -0,0 +1,351 @@ +import type { Plugin } from "@/plugins/core/types"; +import { BasePlugin } from "@/plugins/core/settings"; +import { + booleanSetting, + defineSettings, + Setting, +} from "@/plugins/core/settingsHelpers"; + +const settings = defineSettings({ + autoScrollOnClick: booleanSetting({ + default: false, + title: "Auto-scroll navigator on click", + description: + "When you click a lesson directly in the side panel, automatically scroll it to the centre. The prev/next arrows always centre the selected lesson regardless of this setting.", + }), +}); + +class EnhancedNavigationSettings extends BasePlugin { + @Setting(settings.autoScrollOnClick) + autoScrollOnClick!: boolean; +} + +const settingsInstance = new EnhancedNavigationSettings(); + +const ARROW_CONTAINER_ID = "betterseqta-en-arrows"; +const STYLE_ID = "betterseqta-en-styles"; + +const injectStyles = () => { + if (document.getElementById(STYLE_ID)) return; + const style = document.createElement("style"); + style.id = STYLE_ID; + style.textContent = ` + #${ARROW_CONTAINER_ID} { + position: fixed; + right: 24px; + display: flex; + gap: 6px; + z-index: 15; + pointer-events: none; + transition: opacity 0.15s ease; + } + body:has(.outside-container:not(.hide)) #${ARROW_CONTAINER_ID}, + body:has(.bsplus-notifications-panel:not(.hide)) #${ARROW_CONTAINER_ID} { + opacity: 0; + pointer-events: none; + } + body:has(.outside-container:not(.hide)) #${ARROW_CONTAINER_ID} .en-arrow, + body:has(.bsplus-notifications-panel:not(.hide)) #${ARROW_CONTAINER_ID} .en-arrow { + pointer-events: none; + } + #${ARROW_CONTAINER_ID} .en-arrow { + pointer-events: auto; + width: 32px; + height: 32px; + display: inline-flex; + align-items: center; + justify-content: center; + border: none; + border-radius: 6px; + background: rgba(0, 0, 0, 0.08); + color: #000; + cursor: pointer; + transition: background 0.15s ease, transform 0.1s ease; + padding: 0; + } + #${ARROW_CONTAINER_ID} .en-arrow:hover:not(:disabled) { + background: rgba(0, 0, 0, 0.18); + } + #${ARROW_CONTAINER_ID} .en-arrow:active:not(:disabled) { + transform: scale(0.92); + } + #${ARROW_CONTAINER_ID} .en-arrow:disabled { + opacity: 0.35; + cursor: not-allowed; + } + html.dark #${ARROW_CONTAINER_ID} .en-arrow { + background: rgba(255, 255, 255, 0.08); + color: #fff; + } + html.dark #${ARROW_CONTAINER_ID} .en-arrow:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.18); + } + #${ARROW_CONTAINER_ID} .en-arrow svg { + width: 18px; + height: 18px; + fill: currentColor; + display: block; + } + `; + document.head.appendChild(style); +}; + +const getOrderedItems = (navigator: Element): HTMLElement[] => { + const items: HTMLElement[] = []; + const cover = navigator.querySelector("li.cover"); + if (cover) items.push(cover); + const lessons = Array.from( + navigator.querySelectorAll("li.lesson"), + ); + lessons.sort((a, b) => { + const wa = parseInt(a.dataset.week ?? "0", 10); + const wb = parseInt(b.dataset.week ?? "0", 10); + if (wa !== wb) return wa - wb; + const na = parseInt(a.dataset.number ?? "0", 10); + const nb = parseInt(b.dataset.number ?? "0", 10); + return na - nb; + }); + items.push(...lessons); + return items; +}; + +const getSelected = (navigator: Element): HTMLElement | null => + navigator.querySelector("li.selected"); + +const findScrollableAncestor = (el: Element | null): HTMLElement | null => { + let cur: HTMLElement | null = el as HTMLElement | null; + while (cur && cur !== document.body) { + const style = getComputedStyle(cur); + const oy = style.overflowY; + if ( + (oy === "auto" || oy === "scroll" || oy === "overlay") && + cur.scrollHeight > cur.clientHeight + 1 + ) { + return cur; + } + cur = cur.parentElement; + } + return null; +}; + +const scrollSelectedIntoView = (navigator: Element) => { + const selected = getSelected(navigator); + if (!selected) return; + + const scroller = + findScrollableAncestor(selected) ?? (navigator as HTMLElement); + if (!scroller) return; + + const scrollerRect = scroller.getBoundingClientRect(); + const selectedRect = selected.getBoundingClientRect(); + const offset = + selectedRect.top - + scrollerRect.top - + scroller.clientHeight / 2 + + selectedRect.height / 2; + + scroller.scrollTop = Math.max( + 0, + Math.min( + scroller.scrollTop + offset, + scroller.scrollHeight - scroller.clientHeight, + ), + ); +}; + +const positionArrows = () => { + const container = document.getElementById(ARROW_CONTAINER_ID); + if (!container) return; + const ref = + document.getElementById("toolbar") ?? + document.querySelector(".course"); + if (!ref) return; + const rect = ref.getBoundingClientRect(); + const arrowH = container.offsetHeight || 32; + const verticalOffset = 4; + container.style.top = `${Math.max(0, rect.top + (rect.height - arrowH) / 2 + verticalOffset)}px`; +}; + +let scrollOnNextSelect = false; + +const navigate = (course: HTMLElement, direction: "prev" | "next") => { + const nav = course.querySelector(".navigator"); + if (!nav) return; + const items = getOrderedItems(nav); + const selected = getSelected(nav); + const idx = selected ? items.indexOf(selected) : -1; + const target = idx + (direction === "next" ? 1 : -1); + if (target < 0 || target >= items.length) return; + scrollOnNextSelect = true; + items[target].click(); +}; + +const updateArrowState = (course: HTMLElement) => { + const nav = course.querySelector(".navigator"); + const container = document.getElementById(ARROW_CONTAINER_ID); + if (!nav || !container) return; + + const items = getOrderedItems(nav); + const selected = getSelected(nav); + const idx = selected ? items.indexOf(selected) : -1; + + const prev = container.querySelector( + 'button[data-en-action="prev"]', + ); + const next = container.querySelector( + 'button[data-en-action="next"]', + ); + if (prev) prev.disabled = idx <= 0; + if (next) next.disabled = idx === -1 || idx >= items.length - 1; +}; + +const ensureArrows = (course: HTMLElement) => { + if (!course.querySelector(".programmeNavigator")) return; + + let container = document.getElementById(ARROW_CONTAINER_ID); + if (!container) { + container = document.createElement("div"); + container.id = ARROW_CONTAINER_ID; + container.innerHTML = ` + + + `; + document.body.appendChild(container); + + container.addEventListener("click", (e) => { + e.preventDefault(); + e.stopPropagation(); + const btn = (e.target as Element).closest( + "button[data-en-action]", + ); + if (!btn) return; + const liveCourse = document.querySelector(".course"); + if (liveCourse) + navigate(liveCourse, btn.dataset.enAction as "prev" | "next"); + }); + } + + positionArrows(); + updateArrowState(course); +}; + +const watchNavigator = (navigator: Element, onChange: () => void) => { + onChange(); + const observer = new MutationObserver((muts) => { + if ( + muts.some( + (m) => + (m.type === "attributes" && m.attributeName === "class") || + m.type === "childList", + ) + ) { + onChange(); + } + }); + observer.observe(navigator, { + subtree: true, + attributes: true, + attributeFilter: ["class"], + childList: true, + }); + return observer; +}; + +const handleSlidePane = (pane: Element) => { + const navigator = pane.querySelector(".navigator"); + if (!navigator) return; + + requestAnimationFrame(() => scrollSelectedIntoView(navigator)); + setTimeout(() => scrollSelectedIntoView(navigator), 50); + + const observer = new MutationObserver(() => + scrollSelectedIntoView(navigator), + ); + observer.observe(navigator, { + subtree: true, + attributes: true, + attributeFilter: ["class"], + childList: true, + }); + + const cleanup = new MutationObserver((muts) => { + muts.forEach((m) => { + m.removedNodes.forEach((n) => { + if (n === pane) { + observer.disconnect(); + cleanup.disconnect(); + } + }); + }); + }); + cleanup.observe(document.body, { childList: true }); +}; + +const enhancedNavigationPlugin: Plugin = { + id: "enhanced-navigation", + name: "Enhanced Navigation", + description: + "Keeps the course navigator focused on the current lesson and adds prev/next lesson arrows.", + version: "1.0.0", + disableToggle: true, + settings: settingsInstance.settings, + beta: false, + + run: async (api) => { + injectStyles(); + + window.addEventListener("resize", positionArrows); + window.addEventListener("scroll", positionArrows, true); + + api.seqta.onMount(".course", async (element) => { + const course = element as HTMLElement; + let navObserver: MutationObserver | null = null; + + const setup = () => { + const nav = course.querySelector(".navigator"); + if (!nav) return false; + if (navObserver) return true; + + ensureArrows(course); + navObserver = watchNavigator(nav, () => { + if (scrollOnNextSelect || api.settings.autoScrollOnClick) { + scrollSelectedIntoView(nav); + scrollOnNextSelect = false; + } + ensureArrows(course); + }); + return true; + }; + + if (!setup()) { + const courseObserver = new MutationObserver(() => { + if (setup()) courseObserver.disconnect(); + }); + courseObserver.observe(course, { childList: true, subtree: true }); + } + }); + + const bodyObserver = new MutationObserver((muts) => { + muts.forEach((m) => { + m.addedNodes.forEach((n) => { + if (n.nodeType !== 1) return; + const el = n as Element; + if (el.classList?.contains("uiSlidePane")) handleSlidePane(el); + }); + }); + }); + bodyObserver.observe(document.body, { childList: true }); + + return () => { + bodyObserver.disconnect(); + document.getElementById(ARROW_CONTAINER_ID)?.remove(); + document.getElementById(STYLE_ID)?.remove(); + }; + }, +}; + +export default enhancedNavigationPlugin; diff --git a/src/plugins/index.ts b/src/plugins/index.ts index 9a6ca1fa..84e57836 100644 --- a/src/plugins/index.ts +++ b/src/plugins/index.ts @@ -11,6 +11,7 @@ import profilePicturePlugin from "./built-in/profilePicture"; import assessmentsOverviewPlugin from "./built-in/assessmentsOverview"; import backgroundMusicPlugin from "./built-in/backgroundMusic"; import messageFoldersPlugin from "./built-in/messageFolders"; +import enhancedNavigationPlugin from "./built-in/enhancedNavigation"; //import testPlugin from './built-in/test'; // Heavy plugins (lazy-loaded only when enabled) @@ -31,6 +32,7 @@ pluginManager.registerPlugin(profilePicturePlugin); pluginManager.registerPlugin(assessmentsOverviewPlugin); pluginManager.registerPlugin(backgroundMusicPlugin); pluginManager.registerPlugin(messageFoldersPlugin); +pluginManager.registerPlugin(enhancedNavigationPlugin); //pluginManager.registerPlugin(testPlugin); // Register heavy plugins with lazy loading diff --git a/src/seqta/utils/Openers/OpenWhatsNewPopup.ts b/src/seqta/utils/Openers/OpenWhatsNewPopup.ts index b9e4bb3d..1201a8a0 100644 --- a/src/seqta/utils/Openers/OpenWhatsNewPopup.ts +++ b/src/seqta/utils/Openers/OpenWhatsNewPopup.ts @@ -42,7 +42,8 @@ export function OpenWhatsNewPopup(onDismissed?: () => void) { const text = stringToHTML(/* html */ `
-

3.7.0 – Grade Analytics, Global Search & SEQTA Engage Improvements

+

3.7.0 – Grade Analytics, Enhanced Navigation, Global Search & SEQTA Engage Improvements

+
  • Added Enhanced Navigation for courses: the navigator now auto-scrolls to the selected lesson (e.g. inside the "Go to…" popup) and prev/next arrows for jumping between lessons.
  • Added Grade Analytics, new sidebar page with grade trend charts synced from SEQTA.
  • Added Grade distribution auto-detects your school’s letter scale from released marks for analytics page.
  • Added documents, notices, portals, folios, goals, and more to Global Search.