mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-06-07 20:24:39 +00:00
feat: Improved navigation in courses (via a plugin)
What it does: - Makes the course navigator focused on the currently selected lesson instead of the top upon opening - Adds arrows to the course page, to allow for easier navigation.
This commit is contained in:
@@ -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<typeof settings> {
|
||||
@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<HTMLElement>("li.cover");
|
||||
if (cover) items.push(cover);
|
||||
const lessons = Array.from(
|
||||
navigator.querySelectorAll<HTMLElement>("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<HTMLElement>("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<HTMLElement>(".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<HTMLButtonElement>(
|
||||
'button[data-en-action="prev"]',
|
||||
);
|
||||
const next = container.querySelector<HTMLButtonElement>(
|
||||
'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 = `
|
||||
<button type="button" class="en-arrow" data-en-action="prev" title="Previous lesson" aria-label="Previous lesson">
|
||||
<svg viewBox="0 0 24 24"><path d="M15.41 7.41 14 6l-6 6 6 6 1.41-1.41L10.83 12z"/></svg>
|
||||
</button>
|
||||
<button type="button" class="en-arrow" data-en-action="next" title="Next lesson" aria-label="Next lesson">
|
||||
<svg viewBox="0 0 24 24"><path d="M8.59 16.59 10 18l6-6-6-6-1.41 1.41L13.17 12z"/></svg>
|
||||
</button>
|
||||
`;
|
||||
document.body.appendChild(container);
|
||||
|
||||
container.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const btn = (e.target as Element).closest<HTMLButtonElement>(
|
||||
"button[data-en-action]",
|
||||
);
|
||||
if (!btn) return;
|
||||
const liveCourse = document.querySelector<HTMLElement>(".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<typeof settings> = {
|
||||
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;
|
||||
@@ -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
|
||||
|
||||
@@ -42,7 +42,8 @@ export function OpenWhatsNewPopup(onDismissed?: () => void) {
|
||||
const text = stringToHTML(/* html */ `
|
||||
<div class="whatsnewTextContainer" style="height: 50%;overflow-y: auto;">
|
||||
|
||||
<h1>3.7.0 – Grade Analytics, Global Search & SEQTA Engage Improvements</h1>
|
||||
<h1>3.7.0 – Grade Analytics, Enhanced Navigation, Global Search & SEQTA Engage Improvements</h1>
|
||||
<li>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.</li>
|
||||
<li>Added Grade Analytics, new sidebar page with grade trend charts synced from SEQTA.</li>
|
||||
<li>Added Grade distribution auto-detects your school’s letter scale from released marks for analytics page.</li>
|
||||
<li>Added documents, notices, portals, folios, goals, and more to Global Search.</li>
|
||||
|
||||
Reference in New Issue
Block a user