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 assessmentsOverviewPlugin from "./built-in/assessmentsOverview";
|
||||||
import backgroundMusicPlugin from "./built-in/backgroundMusic";
|
import backgroundMusicPlugin from "./built-in/backgroundMusic";
|
||||||
import messageFoldersPlugin from "./built-in/messageFolders";
|
import messageFoldersPlugin from "./built-in/messageFolders";
|
||||||
|
import enhancedNavigationPlugin from "./built-in/enhancedNavigation";
|
||||||
//import testPlugin from './built-in/test';
|
//import testPlugin from './built-in/test';
|
||||||
|
|
||||||
// Heavy plugins (lazy-loaded only when enabled)
|
// Heavy plugins (lazy-loaded only when enabled)
|
||||||
@@ -31,6 +32,7 @@ pluginManager.registerPlugin(profilePicturePlugin);
|
|||||||
pluginManager.registerPlugin(assessmentsOverviewPlugin);
|
pluginManager.registerPlugin(assessmentsOverviewPlugin);
|
||||||
pluginManager.registerPlugin(backgroundMusicPlugin);
|
pluginManager.registerPlugin(backgroundMusicPlugin);
|
||||||
pluginManager.registerPlugin(messageFoldersPlugin);
|
pluginManager.registerPlugin(messageFoldersPlugin);
|
||||||
|
pluginManager.registerPlugin(enhancedNavigationPlugin);
|
||||||
//pluginManager.registerPlugin(testPlugin);
|
//pluginManager.registerPlugin(testPlugin);
|
||||||
|
|
||||||
// Register heavy plugins with lazy loading
|
// Register heavy plugins with lazy loading
|
||||||
|
|||||||
@@ -42,7 +42,8 @@ export function OpenWhatsNewPopup(onDismissed?: () => void) {
|
|||||||
const text = stringToHTML(/* html */ `
|
const text = stringToHTML(/* html */ `
|
||||||
<div class="whatsnewTextContainer" style="height: 50%;overflow-y: auto;">
|
<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 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 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>
|
<li>Added documents, notices, portals, folios, goals, and more to Global Search.</li>
|
||||||
|
|||||||
Reference in New Issue
Block a user