mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-08-28 17:21:05 +00:00
feat: replace sidebar with svelte alternative
This commit is contained in:
@@ -37,8 +37,6 @@ function registerFetchSeqtaAppLinkListener() {
|
||||
});
|
||||
}
|
||||
|
||||
export let MenuOptionsOpen = false;
|
||||
|
||||
var IsSEQTAPage = false;
|
||||
let hasSEQTAText = false;
|
||||
|
||||
|
||||
@@ -614,6 +614,154 @@ ul.magicDelete > li.deleting {
|
||||
border-right: none;
|
||||
transition: width 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* Hide native menu as soon as possible (during loading), before Svelte mounts.
|
||||
Keep `#bsplus-sidebar-root` visible — themes target `#menu > ul > li.item` on it. */
|
||||
html.bsplus-custom-sidebar-pending #menu > ul:not(#bsplus-sidebar-root),
|
||||
html.bsplus-custom-sidebar-pending #menu > #userInfo,
|
||||
html.bsplus-custom-sidebar-pending #menu > .icon-cover,
|
||||
#menu.bsplus-custom-sidebar > ul:not(#bsplus-sidebar-root),
|
||||
#menu.bsplus-custom-sidebar > #userInfo,
|
||||
#menu.bsplus-custom-sidebar > .icon-cover {
|
||||
position: absolute !important;
|
||||
left: -10000px !important;
|
||||
width: 1px !important;
|
||||
height: 1px !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
overflow: hidden !important;
|
||||
opacity: 0 !important;
|
||||
pointer-events: none !important;
|
||||
clip: rect(0, 0, 0, 0) !important;
|
||||
}
|
||||
|
||||
/* Custom Svelte sidebar: SEQTA-like `#menu > ul > li.item` / `.sub` for theme CSS. */
|
||||
#menu.bsplus-custom-sidebar {
|
||||
/* SEQTA uses absolute top/bottom:0 — do not switch to relative or height collapses. */
|
||||
position: absolute !important;
|
||||
top: 0 !important;
|
||||
bottom: 0 !important;
|
||||
left: 0 !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
/* No padding-top: logo `ul::before` + theme `.sub { top: 71.5px }` match native. */
|
||||
box-sizing: border-box !important;
|
||||
height: auto !important;
|
||||
max-height: none !important;
|
||||
overflow: hidden !important;
|
||||
|
||||
> #bsplus-sidebar-root {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 2;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Theme drill panels assume static hasChildren + absolute .sub filling #menu. */
|
||||
li.hasChildren {
|
||||
position: static !important;
|
||||
}
|
||||
|
||||
/*
|
||||
* Native SEQTA leaves `li.hasChildren.active` on the hidden list after click-through.
|
||||
* Global `#menu:has(> ul > li.hasChildren.active) > ul > li:not(.hasChildren.active)`
|
||||
* then sets pointer-events:none on our custom root rows. Override that here; drill
|
||||
* locking only follows the custom list's own active folder.
|
||||
*/
|
||||
> #bsplus-sidebar-root > li.item {
|
||||
pointer-events: auto !important;
|
||||
}
|
||||
|
||||
/* Only lock siblings while a drill `.sub` is open — not when a folder is
|
||||
merely route-highlighted with `.active` at the root list. */
|
||||
&:has(> #bsplus-sidebar-root > li.hasChildren.active > .sub)
|
||||
> #bsplus-sidebar-root
|
||||
> li:not(.hasChildren.active),
|
||||
&:has(> #bsplus-sidebar-root > li.hasChildren.active > .sub)
|
||||
> #bsplus-sidebar-root
|
||||
> li:not(.hasChildren.active)
|
||||
* {
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
> #bsplus-sidebar-root .sub {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
> #bsplus-sidebar-root li.hasChildren.active > .sub {
|
||||
pointer-events: auto;
|
||||
/* Let the #menu chrome (gradient / theme) show through — themes often paint
|
||||
`.sub` with flat `--background-primary` which reads as a white slab. */
|
||||
background: transparent !important;
|
||||
background-image: none !important;
|
||||
animation: bsplus-submenu-enter 0.32s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
> #bsplus-sidebar-root li.hasChildren.active > .sub:has(.hasChildren.active) {
|
||||
pointer-events: none !important;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
> #bsplus-sidebar-root li.hasChildren.active .hasChildren.active > .sub {
|
||||
pointer-events: auto !important;
|
||||
background: transparent !important;
|
||||
background-image: none !important;
|
||||
animation: bsplus-submenu-enter 0.32s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
/* Keep folder + leaf rows on the same rhythm (themes often only style
|
||||
`:not(.hasChildren)`, which left folders centred / differently spaced). */
|
||||
> #bsplus-sidebar-root li.item {
|
||||
width: auto !important;
|
||||
max-width: none !important;
|
||||
margin: 2px 6px !important;
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
|
||||
/* Folder label stays in flow so sidebar-animation translateX can run.
|
||||
Visually covered by the absolute `.sub` panel. */
|
||||
> #bsplus-sidebar-root > li.hasChildren.active > label {
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
#bsplus-sidebar-root .sub .back {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 12px 16px 8px;
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
#bsplus-sidebar-root .sub .back:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
#bsplus-sidebar-root .sub .backLabel {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
#bsplus-sidebar-root .sub > ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0 0 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bsplus-submenu-enter {
|
||||
from {
|
||||
opacity: 0.35;
|
||||
transform: translateX(28px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
#menu li > label > svg,
|
||||
#menu section > label > svg {
|
||||
margin: 0 10px 0 4px;
|
||||
|
||||
@@ -66,11 +66,16 @@ const assessmentsOverviewPlugin: Plugin<{}> = {
|
||||
label.textContent = "Overview";
|
||||
gridItem.appendChild(label);
|
||||
menu.insertBefore(gridItem, menu.firstChild);
|
||||
window.dispatchEvent(new CustomEvent("bsplus-native-menu-updated"));
|
||||
|
||||
let loadRequestId = 0;
|
||||
|
||||
const menuObserver = new MutationObserver(() => {
|
||||
const before = menu.firstElementChild;
|
||||
ensureOverviewMenuPosition(menu, gridItem);
|
||||
if (menu.firstElementChild !== before) {
|
||||
window.dispatchEvent(new CustomEvent("bsplus-native-menu-updated"));
|
||||
}
|
||||
});
|
||||
menuObserver.observe(menu, { childList: true });
|
||||
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
import type { Plugin } from "@/plugins/core/types";
|
||||
import MenuitemSVGKey from "@/seqta/content/MenuItemSVGKey.json";
|
||||
import { waitForElm } from "@/seqta/utils/waitForElm";
|
||||
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
|
||||
import {
|
||||
ensureAnalyticsMenuOrder,
|
||||
insertMenuItemAfterKey,
|
||||
processMenuItemNode,
|
||||
} from "@/seqta/utils/sidebarMenuIcons";
|
||||
import {
|
||||
ChangeMenuItemPositions,
|
||||
MenuOptionsOpen,
|
||||
} from "@/seqta/utils/Openers/OpenMenuOptions";
|
||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||
import { applyMenuItemVisibility } from "@/seqta/utils/menuItemVisibility";
|
||||
import { loadAnalyticsPage } from "../loadAnalyticsPage";
|
||||
import styles from "../styles.css?inline";
|
||||
|
||||
const ANALYTICS_MENU_ICON = MenuitemSVGKey.analytics;
|
||||
const ANALYTICS_MENU_CLASS = "betterseqta-grade-analytics-item";
|
||||
|
||||
const gradeAnalyticsPlugin: Plugin<{}> = {
|
||||
id: "grade-analytics",
|
||||
name: "Grade Analytics",
|
||||
description:
|
||||
"Adds an analytics page with grade trends, distribution charts, and assessment history",
|
||||
version: "1.0.0",
|
||||
settings: {},
|
||||
disableToggle: true,
|
||||
styles,
|
||||
|
||||
run: async () => {
|
||||
if (isSeqtaEngageExperience()) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const menuList = (await waitForElm("#menu > ul, #menu ul", true, 100, 60)) as HTMLElement;
|
||||
|
||||
const analyticsItem = document.createElement("li");
|
||||
analyticsItem.className = "item";
|
||||
analyticsItem.classList.add(ANALYTICS_MENU_CLASS);
|
||||
analyticsItem.id = "analyticsbutton";
|
||||
analyticsItem.dataset.key = "analytics";
|
||||
analyticsItem.dataset.path = "/analytics";
|
||||
analyticsItem.dataset.betterseqta = "true";
|
||||
analyticsItem.innerHTML = `<label>${ANALYTICS_MENU_ICON}<span>Analytics</span></label>`;
|
||||
|
||||
const syncAnalyticsMenu = () => {
|
||||
insertMenuItemAfterKey(menuList, analyticsItem, "courses");
|
||||
ensureAnalyticsMenuOrder();
|
||||
if (settingsState.menuorder.length > 0) {
|
||||
ChangeMenuItemPositions(settingsState.menuorder);
|
||||
}
|
||||
processMenuItemNode(analyticsItem);
|
||||
applyMenuItemVisibility();
|
||||
};
|
||||
|
||||
syncAnalyticsMenu();
|
||||
|
||||
const menuObserver = new MutationObserver(() => {
|
||||
if (MenuOptionsOpen || menuList.contains(analyticsItem)) return;
|
||||
syncAnalyticsMenu();
|
||||
});
|
||||
menuObserver.observe(menuList, { childList: true });
|
||||
|
||||
analyticsItem.addEventListener("click", (e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (
|
||||
MenuOptionsOpen ||
|
||||
analyticsItem.classList.contains("draggable") ||
|
||||
target.closest(".onoffswitch, .editmenuoption-container")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
window.history.pushState({}, "", "/#?page=/analytics");
|
||||
void loadAnalyticsPage();
|
||||
});
|
||||
|
||||
return () => {
|
||||
menuObserver.disconnect();
|
||||
analyticsItem.remove();
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export default gradeAnalyticsPlugin;
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import MenuitemSVGKey from "@/seqta/content/MenuItemSVGKey.json";
|
||||
import { waitForElm } from "@/seqta/utils/waitForElm";
|
||||
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
|
||||
import {
|
||||
ensureAnalyticsMenuOrder,
|
||||
insertMenuItemAfterKey,
|
||||
} from "@/seqta/utils/Openers/analyticsMenuOrder";
|
||||
import { processMenuItemNode } from "@/seqta/utils/sidebarMenuIcons";
|
||||
import { ChangeMenuItemPositions } from "@/seqta/utils/Openers/menuOrder";
|
||||
import { isMenuOptionsOpen } from "@/seqta/utils/Openers/menuOptionsState";
|
||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||
import { applyMenuItemVisibility } from "@/seqta/utils/menuItemVisibility";
|
||||
|
||||
const ANALYTICS_MENU_ICON = MenuitemSVGKey.analytics;
|
||||
export const ANALYTICS_MENU_CLASS = "betterseqta-grade-analytics-item";
|
||||
export const ANALYTICS_MENU_KEY = "analytics";
|
||||
|
||||
function getNativeMenuList(): HTMLElement | null {
|
||||
const menu = document.getElementById("menu");
|
||||
if (!menu) return null;
|
||||
return menu.querySelector(
|
||||
":scope > ul:not(#bsplus-sidebar-root)",
|
||||
) as HTMLElement | null;
|
||||
}
|
||||
|
||||
function loadAnalyticsPageLazy() {
|
||||
void import("./loadAnalyticsPage").then((m) => m.loadAnalyticsPage());
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject the Analytics sidebar row immediately (eager).
|
||||
* The analytics page chunk stays lazy until click / direct navigation.
|
||||
*/
|
||||
export async function injectAnalyticsMenuItem(): Promise<() => void> {
|
||||
if (isSeqtaEngageExperience()) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
document
|
||||
.querySelectorAll(
|
||||
`#menu .${ANALYTICS_MENU_CLASS}, #menu [data-key="${ANALYTICS_MENU_KEY}"]`,
|
||||
)
|
||||
.forEach((node) => {
|
||||
// Only touch the native list copy — custom sidebar mirrors via sync.
|
||||
if (node.closest("#bsplus-sidebar-root")) return;
|
||||
node.remove();
|
||||
});
|
||||
|
||||
await waitForElm(
|
||||
"#menu > ul:not(#bsplus-sidebar-root), #menu > ul",
|
||||
true,
|
||||
50,
|
||||
120,
|
||||
);
|
||||
|
||||
let menuList = getNativeMenuList();
|
||||
if (!menuList) {
|
||||
menuList = (await waitForElm(
|
||||
"#menu > ul:not(#bsplus-sidebar-root), #menu ul",
|
||||
true,
|
||||
50,
|
||||
120,
|
||||
)) as HTMLElement;
|
||||
}
|
||||
|
||||
const analyticsItem = document.createElement("li");
|
||||
analyticsItem.className = "item";
|
||||
analyticsItem.classList.add(ANALYTICS_MENU_CLASS);
|
||||
analyticsItem.id = "analyticsbutton";
|
||||
analyticsItem.dataset.key = ANALYTICS_MENU_KEY;
|
||||
analyticsItem.dataset.path = "/analytics";
|
||||
analyticsItem.dataset.betterseqta = "true";
|
||||
analyticsItem.innerHTML = `<label>${ANALYTICS_MENU_ICON}<span>Analytics</span></label>`;
|
||||
|
||||
const syncAnalyticsMenu = () => {
|
||||
const list = getNativeMenuList() ?? menuList;
|
||||
if (!list) return;
|
||||
|
||||
if (list.querySelector(':scope > [data-key="courses"]')) {
|
||||
insertMenuItemAfterKey(list, analyticsItem, "courses");
|
||||
} else if (list.querySelector(':scope > [data-key="home"]')) {
|
||||
insertMenuItemAfterKey(list, analyticsItem, "home");
|
||||
} else if (!list.contains(analyticsItem)) {
|
||||
list.insertBefore(analyticsItem, list.firstChild);
|
||||
}
|
||||
|
||||
ensureAnalyticsMenuOrder();
|
||||
if (settingsState.menuorder.length > 0) {
|
||||
ChangeMenuItemPositions(settingsState.menuorder);
|
||||
}
|
||||
processMenuItemNode(analyticsItem);
|
||||
applyMenuItemVisibility();
|
||||
window.dispatchEvent(new CustomEvent("bsplus-native-menu-updated"));
|
||||
};
|
||||
|
||||
syncAnalyticsMenu();
|
||||
|
||||
const menuObserver = new MutationObserver(() => {
|
||||
const list = getNativeMenuList() ?? menuList;
|
||||
if (!list || isMenuOptionsOpen() || list.contains(analyticsItem)) return;
|
||||
syncAnalyticsMenu();
|
||||
});
|
||||
menuObserver.observe(menuList, { childList: true });
|
||||
|
||||
const onClick = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (
|
||||
isMenuOptionsOpen() ||
|
||||
analyticsItem.classList.contains("draggable") ||
|
||||
target.closest(".onoffswitch, .editmenuoption-container")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
window.history.pushState({}, "", "/#?page=/analytics");
|
||||
loadAnalyticsPageLazy();
|
||||
};
|
||||
analyticsItem.addEventListener("click", onClick);
|
||||
|
||||
return () => {
|
||||
menuObserver.disconnect();
|
||||
analyticsItem.removeEventListener("click", onClick);
|
||||
analyticsItem.remove();
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { defineLazyPlugin } from "../../core/dynamicLoader";
|
||||
import { defineSettings, numberSetting } from "../../core/settingsHelpers";
|
||||
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
|
||||
import { injectAnalyticsMenuItem } from "./injectAnalyticsMenuItem";
|
||||
import styles from "./styles.css?inline";
|
||||
|
||||
const settings = defineSettings({
|
||||
@@ -13,6 +14,10 @@ const settings = defineSettings({
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Shell loads immediately so the sidebar Analytics icon can mount.
|
||||
* The page UI (`loadAnalyticsPage` / charts) stays in a separate lazy chunk.
|
||||
*/
|
||||
const gradeAnalyticsPluginLazy = defineLazyPlugin({
|
||||
id: "grade-analytics",
|
||||
name: "Grade Analytics",
|
||||
@@ -23,16 +28,21 @@ const gradeAnalyticsPluginLazy = defineLazyPlugin({
|
||||
disableToggle: true,
|
||||
defaultEnabled: true,
|
||||
styles,
|
||||
// Kept for API compatibility; menu injection no longer waits on this chunk.
|
||||
loader: () => import("./core/index"),
|
||||
});
|
||||
|
||||
const runGradeAnalytics = gradeAnalyticsPluginLazy.run!;
|
||||
|
||||
gradeAnalyticsPluginLazy.run = async (api) => {
|
||||
gradeAnalyticsPluginLazy.run = async () => {
|
||||
if (isSeqtaEngageExperience()) {
|
||||
return () => {};
|
||||
}
|
||||
return runGradeAnalytics(api);
|
||||
|
||||
// Eager: sidebar icon / native menu row only.
|
||||
const cleanupMenu = await injectAnalyticsMenuItem();
|
||||
|
||||
return () => {
|
||||
cleanupMenu();
|
||||
};
|
||||
};
|
||||
|
||||
export default gradeAnalyticsPluginLazy;
|
||||
|
||||
@@ -37,8 +37,10 @@ const PLUGIN_START_PHASES: readonly string[][] = [
|
||||
"messageFolders",
|
||||
"profile-picture",
|
||||
"background-music",
|
||||
// Menu icon is eager; page UI still loads on demand via dynamic import.
|
||||
"grade-analytics",
|
||||
],
|
||||
["global-search", "grade-analytics"],
|
||||
["global-search"],
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -188,6 +188,9 @@ function SortMessagePageItems(messagesParentElement: any) {
|
||||
|
||||
async function LoadPageElements(): Promise<void> {
|
||||
await AddBetterSEQTAElements();
|
||||
void import("@/seqta/ui/sidebar/mountCustomSidebar").then((mod) => {
|
||||
void mod.mountCustomSidebar();
|
||||
});
|
||||
const sublink: string | undefined = getEngageRoutePage();
|
||||
|
||||
if (isSeqtaEngageExperience() && !engageHashListenerAttached) {
|
||||
@@ -676,6 +679,13 @@ export function init() {
|
||||
}
|
||||
|
||||
document.querySelector(".legacy-root")?.classList.add("hidden");
|
||||
|
||||
// Hide native sidebar + mount Svelte replacement during the loading overlay.
|
||||
document.documentElement.classList.add("bsplus-custom-sidebar-pending");
|
||||
void import("@/seqta/ui/sidebar/mountCustomSidebar").then((mod) => {
|
||||
mod.prepareCustomSidebarEarly();
|
||||
});
|
||||
|
||||
void observeMenuItemPosition();
|
||||
|
||||
new StorageChangeHandler();
|
||||
|
||||
@@ -144,6 +144,7 @@ export async function AddBetterSEQTAElements() {
|
||||
setupEventListeners();
|
||||
await addDarkLightToggle();
|
||||
customizeMenuToggle();
|
||||
// Kept as fallback if the custom Svelte sidebar fails to mount.
|
||||
setupSidebarAccessibility();
|
||||
}
|
||||
|
||||
@@ -672,6 +673,20 @@ function updateSidebarAccessibility() {
|
||||
const menu = document.getElementById("menu");
|
||||
if (!menu) return;
|
||||
|
||||
// Custom Svelte sidebar owns its own a11y / drill UI — do not mark its
|
||||
// `#bsplus-sidebar-root` items offscreen based on the hidden native list.
|
||||
if (menu.classList.contains("bsplus-custom-sidebar")) {
|
||||
const root = document.getElementById("bsplus-sidebar-root");
|
||||
if (root) {
|
||||
for (const entry of root.querySelectorAll(`.${BSPLUS_SIDEBAR_OFFSCREEN}`)) {
|
||||
if (entry instanceof HTMLElement) {
|
||||
entry.classList.remove(BSPLUS_SIDEBAR_OFFSCREEN);
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const visibleList = getVisibleSidebarList(menu);
|
||||
const visibleEntries = new Set(
|
||||
visibleList ? getDirectSidebarEntries(visibleList) : [],
|
||||
@@ -717,7 +732,9 @@ function getDirectSidebarEntries(list: HTMLElement) {
|
||||
}
|
||||
|
||||
function getVisibleSidebarList(menu: HTMLElement) {
|
||||
let currentList = menu.querySelector(":scope > ul") as HTMLElement | null;
|
||||
let currentList = menu.querySelector(
|
||||
":scope > ul:not(#bsplus-sidebar-root)",
|
||||
) as HTMLElement | null;
|
||||
|
||||
while (currentList) {
|
||||
const activeSubmenuParent = currentList.querySelector(
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
<script lang="ts">
|
||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||
import {
|
||||
restoreCustomMenuActive,
|
||||
sidebarState,
|
||||
} from "./sidebarState.svelte";
|
||||
import SidebarItem from "./SidebarItem.svelte";
|
||||
import type {
|
||||
SidebarDrillFrame,
|
||||
SidebarItem as SidebarItemModel,
|
||||
} from "./types";
|
||||
|
||||
type Props = {
|
||||
menuEl: HTMLElement;
|
||||
};
|
||||
|
||||
let { menuEl }: Props = $props();
|
||||
|
||||
let dragKey = $state<string | null>(null);
|
||||
let coverEl: HTMLElement | null = null;
|
||||
|
||||
const BACK_SVG = `<svg width="24" height="24" viewBox="0 0 24 24" aria-hidden="true"><g style="fill: currentcolor;"><path d="M15.422 16.078l-1.406 1.406-6-6 6-6 1.406 1.406-4.594 4.594z"></path></g></svg>`;
|
||||
|
||||
function itemVisible(key: string): boolean {
|
||||
return (
|
||||
(settingsState.menuitems as Record<string, { toggle?: boolean }>)[key]
|
||||
?.toggle !== false
|
||||
);
|
||||
}
|
||||
|
||||
function onActivate(item: SidebarItemModel) {
|
||||
sidebarState.activateItem(item, menuEl);
|
||||
}
|
||||
|
||||
function closeEdit() {
|
||||
sidebarState.setEditMode(false);
|
||||
menuEl.classList.remove("bsplus-sidebar-edit-mode");
|
||||
void import("@/seqta/utils/Openers/menuOptionsState").then((mod) => {
|
||||
mod.setMenuOptionsOpen(false);
|
||||
});
|
||||
}
|
||||
|
||||
function findFolder(
|
||||
items: SidebarItemModel[],
|
||||
key: string,
|
||||
): SidebarItemModel | null {
|
||||
for (const item of items) {
|
||||
if (item.key === key) return item;
|
||||
if (item.children.length) {
|
||||
const nested = findFolder(item.children, key);
|
||||
if (nested) return nested;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Folder meta for each drill frame (icon/label from the live tree). */
|
||||
const drillFolders = $derived.by(() => {
|
||||
return sidebarState.drillStack.map((frame) => {
|
||||
const folder = findFolder(sidebarState.items, frame.key);
|
||||
return { frame, folder };
|
||||
});
|
||||
});
|
||||
|
||||
function onToggleVisible(key: string, visible: boolean) {
|
||||
sidebarState.setItemVisibility(key, visible);
|
||||
}
|
||||
|
||||
function onDragStart(key: string) {
|
||||
dragKey = key;
|
||||
}
|
||||
|
||||
function onDrop(key: string) {
|
||||
if (dragKey && sidebarState.editMode) {
|
||||
sidebarState.reorderRoot(dragKey, key);
|
||||
}
|
||||
dragKey = null;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const editing = sidebarState.editMode;
|
||||
const container = document.getElementById("container");
|
||||
|
||||
if (editing && container && !coverEl) {
|
||||
const cover = document.createElement("div");
|
||||
cover.className = "notMenuCover bsplus-sidebar-edit-cover";
|
||||
cover.addEventListener("click", closeEdit);
|
||||
container.appendChild(cover);
|
||||
menuEl.style.zIndex = "20";
|
||||
menuEl.classList.add("bsplus-sidebar-edit-mode");
|
||||
coverEl = cover;
|
||||
}
|
||||
|
||||
if (!editing) {
|
||||
coverEl?.remove();
|
||||
coverEl = null;
|
||||
menuEl.style.removeProperty("z-index");
|
||||
menuEl.classList.remove("bsplus-sidebar-edit-mode");
|
||||
}
|
||||
|
||||
return () => {
|
||||
coverEl?.remove();
|
||||
coverEl = null;
|
||||
menuEl.style.removeProperty("z-index");
|
||||
};
|
||||
});
|
||||
|
||||
// SEQTA strips `.active` from `#menu li` after clicks — keep it on our custom
|
||||
// rows so theme animations (palm/sand) and drill `.sub` chrome stay applied.
|
||||
$effect(() => {
|
||||
// Re-run when the route-active key changes so we re-bind the observer target.
|
||||
void sidebarState.activeKey;
|
||||
void sidebarState.isDrilling;
|
||||
|
||||
const root = document.getElementById("bsplus-sidebar-root");
|
||||
if (!root) return;
|
||||
|
||||
restoreCustomMenuActive();
|
||||
|
||||
const observer = new MutationObserver(() => restoreCustomMenuActive());
|
||||
observer.observe(root, {
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ["class"],
|
||||
});
|
||||
|
||||
return () => observer.disconnect();
|
||||
});
|
||||
</script>
|
||||
|
||||
<!--
|
||||
Direct `#menu > ul.logo-link` child so theme selectors like
|
||||
`#menu > ul > li.item` / `#menu .sub` / `:has(> ul > li.hasChildren.active)` match.
|
||||
-->
|
||||
<ul
|
||||
id="bsplus-sidebar-root"
|
||||
class="logo-link bsplus-sidebar-list"
|
||||
class:noscroll={sidebarState.isDrilling}
|
||||
class:drilling={sidebarState.isDrilling}
|
||||
class:compact={sidebarState.compact}
|
||||
class:edit-mode={sidebarState.editMode}
|
||||
aria-label="Main"
|
||||
>
|
||||
{#if sidebarState.editMode}
|
||||
<li class="item bsplus-sidebar-edit-header" aria-hidden="true">
|
||||
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||
<label><span class="label">Edit Sidebar</span></label>
|
||||
</li>
|
||||
{#each sidebarState.items as item (item.key)}
|
||||
<SidebarItem
|
||||
{item}
|
||||
active={sidebarState.activeKey === item.key}
|
||||
compact={sidebarState.compact}
|
||||
editMode={sidebarState.editMode}
|
||||
visible={itemVisible(item.key)}
|
||||
drillEnter={false}
|
||||
{onActivate}
|
||||
{onToggleVisible}
|
||||
{onDragStart}
|
||||
{onDrop}
|
||||
/>
|
||||
{/each}
|
||||
<li class="item bsplus-sidebar-edit-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="edit-btn"
|
||||
onclick={() => sidebarState.restoreDefaultOrder()}
|
||||
>
|
||||
Restore Default
|
||||
</button>
|
||||
<button type="button" class="edit-btn primary" onclick={closeEdit}>
|
||||
Save
|
||||
</button>
|
||||
</li>
|
||||
{:else if drillFolders.length}
|
||||
<!--
|
||||
Keep all root rows in the DOM (like native SEQTA) so sidebar-animation.scss
|
||||
can slide sibling labels away when a folder opens.
|
||||
-->
|
||||
{#each sidebarState.visibleRootItems as item (item.key)}
|
||||
{#if item.key === drillFolders[0].frame.key}
|
||||
{@render drillLevel(drillFolders, 0)}
|
||||
{:else}
|
||||
<SidebarItem
|
||||
{item}
|
||||
active={sidebarState.activeKey === item.key}
|
||||
compact={false}
|
||||
editMode={false}
|
||||
visible={itemVisible(item.key)}
|
||||
drillEnter={false}
|
||||
{onActivate}
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
{:else}
|
||||
{#each sidebarState.visibleRootItems as item (item.key)}
|
||||
<SidebarItem
|
||||
{item}
|
||||
active={sidebarState.activeKey === item.key}
|
||||
compact={sidebarState.compact}
|
||||
editMode={sidebarState.editMode}
|
||||
visible={itemVisible(item.key)}
|
||||
drillEnter={false}
|
||||
{onActivate}
|
||||
{onToggleVisible}
|
||||
{onDragStart}
|
||||
{onDrop}
|
||||
/>
|
||||
{/each}
|
||||
{/if}
|
||||
</ul>
|
||||
|
||||
{#snippet drillLevel(
|
||||
frames: Array<{ frame: SidebarDrillFrame; folder: SidebarItemModel | null }>,
|
||||
depth: number,
|
||||
)}
|
||||
{@const current = frames[depth]}
|
||||
{@const isLast = depth === frames.length - 1}
|
||||
{@const nextKey = isLast ? null : frames[depth + 1].frame.key}
|
||||
{@const folder = current.folder}
|
||||
<li
|
||||
class="item hasChildren bsplus-sidebar-item"
|
||||
class:active={true}
|
||||
data-key={current.frame.key}
|
||||
data-path={folder?.path ?? undefined}
|
||||
style:--item-colour={folder?.itemColour || undefined}
|
||||
>
|
||||
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||
<label>
|
||||
{#if folder?.iconHtml}
|
||||
{@html folder.iconHtml}
|
||||
{/if}
|
||||
<span class="label">{current.frame.label}</span>
|
||||
</label>
|
||||
<div class="sub">
|
||||
<div class="nav">
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="back"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label="Back"
|
||||
onclick={() => sidebarState.goBack()}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
sidebarState.goBack();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{@html BACK_SVG}
|
||||
<div class="backLabel">{current.frame.label}</div>
|
||||
</div>
|
||||
</div>
|
||||
<ul>
|
||||
{#if isLast}
|
||||
{#each current.frame.items as item (item.key)}
|
||||
<SidebarItem
|
||||
{item}
|
||||
active={sidebarState.activeKey === item.key}
|
||||
compact={false}
|
||||
editMode={false}
|
||||
visible={itemVisible(item.key)}
|
||||
drillEnter={true}
|
||||
{onActivate}
|
||||
/>
|
||||
{/each}
|
||||
{:else}
|
||||
<!-- Keep nested siblings for the same slide-away animation. -->
|
||||
{#each current.frame.items as item (item.key)}
|
||||
{#if item.key === nextKey}
|
||||
{@render drillLevel(frames, depth + 1)}
|
||||
{:else}
|
||||
<SidebarItem
|
||||
{item}
|
||||
active={sidebarState.activeKey === item.key}
|
||||
compact={false}
|
||||
editMode={false}
|
||||
visible={itemVisible(item.key)}
|
||||
drillEnter={false}
|
||||
{onActivate}
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
{/snippet}
|
||||
|
||||
<style>
|
||||
.bsplus-sidebar-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0 0 16px;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
color: var(--text-color, #fff);
|
||||
font-family: Rubik, sans-serif;
|
||||
scrollbar-width: thin;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.bsplus-sidebar-list.noscroll,
|
||||
.bsplus-sidebar-list.drilling {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bsplus-sidebar-list.compact {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.bsplus-sidebar-edit-header {
|
||||
pointer-events: none;
|
||||
width: 85%;
|
||||
margin: 8px auto 4px;
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.bsplus-sidebar-edit-header > label {
|
||||
display: block;
|
||||
padding: 12px 16px 4px;
|
||||
}
|
||||
|
||||
.bsplus-sidebar-edit-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
width: 85%;
|
||||
margin: 12px auto 16px;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.edit-btn {
|
||||
flex: 1;
|
||||
padding: 10px 12px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
}
|
||||
|
||||
.edit-btn:hover {
|
||||
background: rgba(0, 0, 0, 0.32);
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.edit-btn:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.edit-btn.primary {
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,236 @@
|
||||
<script lang="ts">
|
||||
import { fly } from "svelte/transition";
|
||||
import type { SidebarItem } from "./types";
|
||||
|
||||
type Props = {
|
||||
item: SidebarItem;
|
||||
active: boolean;
|
||||
compact: boolean;
|
||||
editMode: boolean;
|
||||
visible: boolean;
|
||||
drillEnter?: boolean;
|
||||
onActivate: (item: SidebarItem) => void;
|
||||
onToggleVisible?: (key: string, visible: boolean) => void;
|
||||
onDragStart?: (key: string) => void;
|
||||
onDrop?: (key: string) => void;
|
||||
};
|
||||
|
||||
let {
|
||||
item,
|
||||
active,
|
||||
compact,
|
||||
editMode,
|
||||
visible,
|
||||
drillEnter = false,
|
||||
onActivate,
|
||||
onToggleVisible,
|
||||
onDragStart,
|
||||
onDrop,
|
||||
}: Props = $props();
|
||||
|
||||
const CHEVRON_SVG = `<svg width="24" height="24" viewBox="0 0 24 24" aria-hidden="true"><g style="fill: currentcolor;"><path d="M8.578 16.359l4.594-4.594-4.594-4.594 1.406-1.406 6 6-6 6z"></path></g></svg>`;
|
||||
</script>
|
||||
|
||||
<!-- SEQTA class names (item / hasChildren / active) so theme CSS keeps matching. -->
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_to_interactive_role -->
|
||||
<li
|
||||
class="item bsplus-sidebar-item"
|
||||
class:active={active}
|
||||
class:hasChildren={item.hasChildren}
|
||||
class:edit-mode={editMode}
|
||||
class:hidden-item={editMode && !visible}
|
||||
class:compact={compact}
|
||||
class:draggable={editMode}
|
||||
style:--item-colour={item.itemColour || undefined}
|
||||
data-key={item.key}
|
||||
data-path={item.path ?? undefined}
|
||||
data-betterseqta={item.betterseqta ? "true" : undefined}
|
||||
id={item.id ?? undefined}
|
||||
role="button"
|
||||
tabindex={editMode ? -1 : 0}
|
||||
aria-label={item.label}
|
||||
aria-current={active ? "page" : undefined}
|
||||
draggable={editMode}
|
||||
in:fly={{ x: drillEnter ? 24 : 0, duration: 180 }}
|
||||
ondragstart={() => onDragStart?.(item.key)}
|
||||
ondragover={(e) => e.preventDefault()}
|
||||
ondrop={() => onDrop?.(item.key)}
|
||||
onclick={() => {
|
||||
if (!editMode) onActivate(item);
|
||||
}}
|
||||
onkeydown={(e) => {
|
||||
if (editMode) return;
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onActivate(item);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||
<label>
|
||||
{#if item.iconHtml}
|
||||
<!-- svelte-ignore a11y_distracting_elements -->
|
||||
{@html item.iconHtml}
|
||||
{/if}
|
||||
<span class="label">{item.label}</span>
|
||||
{#if item.hasChildren && !compact && !editMode}
|
||||
<span class="chevron" aria-hidden="true">{@html CHEVRON_SVG}</span>
|
||||
{/if}
|
||||
</label>
|
||||
{#if editMode}
|
||||
<label class="toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={visible}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onchange={(e) =>
|
||||
onToggleVisible?.(
|
||||
item.key,
|
||||
(e.currentTarget as HTMLInputElement).checked,
|
||||
)}
|
||||
/>
|
||||
</label>
|
||||
{/if}
|
||||
{#if item.itemColour && !item.hasChildren}
|
||||
<span class="colour-bar" aria-hidden="true"></span>
|
||||
{/if}
|
||||
</li>
|
||||
|
||||
<style>
|
||||
/* Defaults only — theme #menu rules (often !important) win for look.
|
||||
Keep folder + leaf rows left-aligned (no 85% centred width). */
|
||||
.bsplus-sidebar-item {
|
||||
position: relative;
|
||||
list-style: none;
|
||||
width: auto;
|
||||
margin: 2px 6px;
|
||||
/* Match leaf vertical spacing so folders don't sit lower than neighbours. */
|
||||
padding: 0;
|
||||
border-radius: 12px;
|
||||
color: var(--text-color, #fff);
|
||||
font-family: Rubik, sans-serif;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
transition:
|
||||
background-color 0.2s ease,
|
||||
transform 0.2s ease,
|
||||
opacity 0.2s ease;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.bsplus-sidebar-item > label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
cursor: inherit;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.bsplus-sidebar-item:hover {
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.bsplus-sidebar-item:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px var(--theme-primary, #fff);
|
||||
}
|
||||
|
||||
.bsplus-sidebar-item.active:not(.hasChildren) {
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.bsplus-sidebar-item.compact {
|
||||
width: auto;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.bsplus-sidebar-item.compact > label {
|
||||
padding: 8px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.bsplus-sidebar-item.hidden-item {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.bsplus-sidebar-item.edit-mode {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.bsplus-sidebar-item :global(label > svg) {
|
||||
flex-shrink: 0;
|
||||
margin: 0 10px 0 4px;
|
||||
width: 28px !important;
|
||||
height: 28px !important;
|
||||
}
|
||||
|
||||
.compact :global(label > svg) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.compact .label,
|
||||
.compact .chevron {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.chevron :global(svg) {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.toggle {
|
||||
margin-right: 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.toggle input {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
accent-color: var(--theme-primary, #fff);
|
||||
}
|
||||
|
||||
.colour-bar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 3px;
|
||||
border-radius: 8px 0 0 8px;
|
||||
background: var(--item-colour, transparent);
|
||||
transition: width 100ms ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.bsplus-sidebar-item:hover .colour-bar,
|
||||
.bsplus-sidebar-item.active .colour-bar {
|
||||
width: 6px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,10 @@
|
||||
export {
|
||||
prepareCustomSidebarEarly,
|
||||
mountCustomSidebar,
|
||||
unmountCustomSidebar,
|
||||
openCustomSidebarEditor,
|
||||
closeCustomSidebarEditor,
|
||||
isCustomSidebarMounted,
|
||||
} from "./mountCustomSidebar";
|
||||
export { sidebarState } from "./sidebarState.svelte";
|
||||
export type { SidebarItem, SidebarDrillFrame } from "./types";
|
||||
@@ -0,0 +1,284 @@
|
||||
import { mount, unmount } from "svelte";
|
||||
import type { SettingsState } from "@/types/storage";
|
||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
|
||||
import { waitForElm } from "@/seqta/utils/waitForElm";
|
||||
import Sidebar from "./Sidebar.svelte";
|
||||
import { getNativeMenuList } from "./parseNativeMenu";
|
||||
import {
|
||||
clearNativeDrillActive,
|
||||
sidebarState,
|
||||
} from "./sidebarState.svelte";
|
||||
|
||||
const ROOT_ID = "bsplus-sidebar-root";
|
||||
const MENU_CLASS = "bsplus-custom-sidebar";
|
||||
const PENDING_CLASS = "bsplus-custom-sidebar-pending";
|
||||
|
||||
type ChangeListener = (newValue: unknown, oldValue: unknown) => void;
|
||||
|
||||
let app: ReturnType<typeof mount> | null = null;
|
||||
let menuEl: HTMLElement | null = null;
|
||||
let menuObserver: MutationObserver | null = null;
|
||||
let syncTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let hashListenerAttached = false;
|
||||
let earlyPrepareStarted = false;
|
||||
let catchupTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let nativeMenuListenerAttached = false;
|
||||
|
||||
function onNativeMenuUpdated() {
|
||||
if (menuEl) sidebarState.syncFromNative(menuEl);
|
||||
}
|
||||
|
||||
const settingsListeners: Array<{
|
||||
key: keyof SettingsState;
|
||||
listener: ChangeListener;
|
||||
}> = [];
|
||||
|
||||
function startCatchupSync() {
|
||||
if (catchupTimer) clearInterval(catchupTimer);
|
||||
let attempts = 0;
|
||||
catchupTimer = setInterval(() => {
|
||||
attempts += 1;
|
||||
if (menuEl) sidebarState.syncFromNative(menuEl);
|
||||
// Plugins (Analytics, Overview, icons) inject shortly after first paint.
|
||||
if (attempts >= 60) {
|
||||
if (catchupTimer) clearInterval(catchupTimer);
|
||||
catchupTimer = null;
|
||||
}
|
||||
}, 50);
|
||||
}
|
||||
|
||||
function scheduleSync() {
|
||||
if (syncTimer) clearTimeout(syncTimer);
|
||||
syncTimer = setTimeout(() => {
|
||||
syncTimer = null;
|
||||
if (menuEl) sidebarState.syncFromNative(menuEl);
|
||||
}, 50);
|
||||
}
|
||||
|
||||
function onHashChange() {
|
||||
sidebarState.syncActiveFromLocation();
|
||||
if (menuEl) clearNativeDrillActive(menuEl);
|
||||
|
||||
if (!sidebarState.isDrilling) return;
|
||||
|
||||
const path = sidebarState.activePath;
|
||||
if (!path) return;
|
||||
|
||||
const openKey = sidebarState.drillStack[0]?.key;
|
||||
const top = sidebarState.visibleRootItems.find(
|
||||
(item) =>
|
||||
item.key === openKey ||
|
||||
item.path === path ||
|
||||
(item.path != null && path.startsWith(`${item.path}/`)),
|
||||
);
|
||||
|
||||
if (openKey && top?.key === openKey) return;
|
||||
if (!path.includes("/")) sidebarState.resetDrill();
|
||||
}
|
||||
|
||||
function ensureDefaultMenuOrder(menu: HTMLElement) {
|
||||
const list = getNativeMenuList(menu);
|
||||
if (!list) return;
|
||||
const keys = [...list.children]
|
||||
.map((node) => (node as HTMLElement).dataset.key)
|
||||
.filter((key): key is string => !!key);
|
||||
|
||||
if (!settingsState.defaultmenuorder?.length) {
|
||||
settingsState.defaultmenuorder = keys;
|
||||
return;
|
||||
}
|
||||
|
||||
for (const key of keys) {
|
||||
if (!settingsState.defaultmenuorder.includes(key)) {
|
||||
settingsState.defaultmenuorder = [
|
||||
...settingsState.defaultmenuorder,
|
||||
key,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function registerSetting(
|
||||
key: keyof SettingsState,
|
||||
listener: ChangeListener,
|
||||
) {
|
||||
settingsState.register(key, listener);
|
||||
settingsListeners.push({ key, listener });
|
||||
}
|
||||
|
||||
function clearSettingListeners() {
|
||||
for (const { key, listener } of settingsListeners) {
|
||||
settingsState.unregister(key, listener);
|
||||
}
|
||||
settingsListeners.length = 0;
|
||||
}
|
||||
|
||||
function clearPendingClass() {
|
||||
document.documentElement.classList.remove(PENDING_CLASS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the native SEQTA menu immediately (even under the loading overlay)
|
||||
* and begin mounting the Svelte sidebar as soon as `#menu` exists.
|
||||
*/
|
||||
export function prepareCustomSidebarEarly() {
|
||||
if (isSeqtaEngageExperience()) return;
|
||||
if (!settingsState.onoff) return;
|
||||
if (earlyPrepareStarted) return;
|
||||
|
||||
earlyPrepareStarted = true;
|
||||
document.documentElement.classList.add(PENDING_CLASS);
|
||||
void mountCustomSidebar();
|
||||
}
|
||||
|
||||
export function openCustomSidebarEditor() {
|
||||
if (!app || !menuEl) return false;
|
||||
sidebarState.setEditMode(true);
|
||||
menuEl.classList.add("bsplus-sidebar-edit-mode");
|
||||
return true;
|
||||
}
|
||||
|
||||
export function closeCustomSidebarEditor() {
|
||||
sidebarState.setEditMode(false);
|
||||
menuEl?.classList.remove("bsplus-sidebar-edit-mode");
|
||||
void import("@/seqta/utils/Openers/menuOptionsState").then((mod) => {
|
||||
mod.setMenuOptionsOpen(false);
|
||||
});
|
||||
}
|
||||
|
||||
export function isCustomSidebarMounted(): boolean {
|
||||
return app != null;
|
||||
}
|
||||
|
||||
export async function mountCustomSidebar(): Promise<boolean> {
|
||||
if (isSeqtaEngageExperience()) return false;
|
||||
if (!settingsState.onoff) return false;
|
||||
|
||||
// Already mounted — re-sync after Home/News/Analytics injections.
|
||||
if (app && menuEl) {
|
||||
ensureDefaultMenuOrder(menuEl);
|
||||
sidebarState.syncSettings();
|
||||
sidebarState.syncFromNative(menuEl);
|
||||
startCatchupSync();
|
||||
clearPendingClass();
|
||||
return true;
|
||||
}
|
||||
|
||||
document.documentElement.classList.add(PENDING_CLASS);
|
||||
|
||||
const menu = (await waitForElm("#menu", true, 50, 200)) as HTMLElement | null;
|
||||
if (!menu) return false;
|
||||
|
||||
// Prefer a populated native list, but don't block forever during loading.
|
||||
let list = getNativeMenuList(menu);
|
||||
if (!list) {
|
||||
await waitForElm("#menu > ul:not(#bsplus-sidebar-root)", true, 50, 120);
|
||||
list = getNativeMenuList(menu);
|
||||
}
|
||||
if (!list) return false;
|
||||
|
||||
if (app) {
|
||||
menuEl = menu;
|
||||
ensureDefaultMenuOrder(menu);
|
||||
sidebarState.syncFromNative(menu);
|
||||
clearPendingClass();
|
||||
return true;
|
||||
}
|
||||
|
||||
menuEl = menu;
|
||||
menu.classList.add(MENU_CLASS);
|
||||
|
||||
// Remove a stale root from a previous HMR / partial mount.
|
||||
document.getElementById(ROOT_ID)?.remove();
|
||||
|
||||
ensureDefaultMenuOrder(menu);
|
||||
sidebarState.syncSettings();
|
||||
sidebarState.syncFromNative(menu);
|
||||
|
||||
// Mount as a direct child of `#menu` so root is `#menu > ul#bsplus-sidebar-root`.
|
||||
app = mount(Sidebar, {
|
||||
target: menu,
|
||||
props: { menuEl: menu },
|
||||
});
|
||||
|
||||
menu
|
||||
.querySelectorAll(".bsplus-sidebar-offscreen")
|
||||
.forEach((node) => node.classList.remove("bsplus-sidebar-offscreen"));
|
||||
|
||||
menuObserver?.disconnect();
|
||||
menuObserver = new MutationObserver((mutations) => {
|
||||
const ours = document.getElementById(ROOT_ID);
|
||||
if (ours && mutations.every((m) => ours.contains(m.target))) return;
|
||||
scheduleSync();
|
||||
});
|
||||
menuObserver.observe(menu, {
|
||||
subtree: true,
|
||||
childList: true,
|
||||
attributes: true,
|
||||
attributeFilter: ["class", "style", "data-key", "data-path", "data-colour"],
|
||||
});
|
||||
|
||||
if (!hashListenerAttached) {
|
||||
window.addEventListener("hashchange", onHashChange);
|
||||
hashListenerAttached = true;
|
||||
}
|
||||
|
||||
if (!nativeMenuListenerAttached) {
|
||||
window.addEventListener("bsplus-native-menu-updated", onNativeMenuUpdated);
|
||||
nativeMenuListenerAttached = true;
|
||||
}
|
||||
|
||||
clearSettingListeners();
|
||||
registerSetting("iconOnlySidebar", () => {
|
||||
sidebarState.syncSettings();
|
||||
});
|
||||
registerSetting("menuorder", () => {
|
||||
if (menuEl) sidebarState.syncFromNative(menuEl);
|
||||
});
|
||||
registerSetting("menuitems", () => {
|
||||
if (menuEl) sidebarState.syncFromNative(menuEl);
|
||||
});
|
||||
|
||||
startCatchupSync();
|
||||
clearPendingClass();
|
||||
return true;
|
||||
}
|
||||
|
||||
export function unmountCustomSidebar() {
|
||||
menuObserver?.disconnect();
|
||||
menuObserver = null;
|
||||
if (syncTimer) clearTimeout(syncTimer);
|
||||
syncTimer = null;
|
||||
if (catchupTimer) clearInterval(catchupTimer);
|
||||
catchupTimer = null;
|
||||
|
||||
clearSettingListeners();
|
||||
|
||||
if (hashListenerAttached) {
|
||||
window.removeEventListener("hashchange", onHashChange);
|
||||
hashListenerAttached = false;
|
||||
}
|
||||
|
||||
if (nativeMenuListenerAttached) {
|
||||
window.removeEventListener(
|
||||
"bsplus-native-menu-updated",
|
||||
onNativeMenuUpdated,
|
||||
);
|
||||
nativeMenuListenerAttached = false;
|
||||
}
|
||||
|
||||
if (app) {
|
||||
unmount(app);
|
||||
app = null;
|
||||
}
|
||||
|
||||
document.getElementById(ROOT_ID)?.remove();
|
||||
menuEl?.classList.remove(MENU_CLASS, "bsplus-sidebar-edit-mode");
|
||||
menuEl = null;
|
||||
sidebarState.resetDrill();
|
||||
sidebarState.setEditMode(false);
|
||||
sidebarState.ready = false;
|
||||
earlyPrepareStarted = false;
|
||||
clearPendingClass();
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* @jest-environment jsdom
|
||||
*/
|
||||
import { getPagePathFromHash, parseNativeMenu } from "./parseNativeMenu";
|
||||
|
||||
describe("parseNativeMenu", () => {
|
||||
it("parses top-level items and nested folders", () => {
|
||||
document.body.innerHTML = `
|
||||
<div id="menu">
|
||||
<ul>
|
||||
<li class="item" data-key="home" id="homebutton" data-path="/home" data-betterseqta="true">
|
||||
<label><svg></svg><span>Home</span></label>
|
||||
</li>
|
||||
<li class="item hasChildren" data-key="assessments" data-path="/assessments">
|
||||
<label><svg></svg><span>Assessments</span></label>
|
||||
<div class="sub"><ul>
|
||||
<li class="item betterseqta-assessments-overview-item" data-betterseqta="true">
|
||||
<label>Overview</label>
|
||||
</li>
|
||||
<li class="item" data-key="upcoming" data-path="/assessments/upcoming">
|
||||
<label>Upcoming</label>
|
||||
</li>
|
||||
</ul></div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const menu = document.getElementById("menu")!;
|
||||
const items = parseNativeMenu(menu);
|
||||
|
||||
expect(items.map((i) => i.key)).toEqual(["home", "assessments"]);
|
||||
expect(items[0].betterseqta).toBe(true);
|
||||
expect(items[1].hasChildren).toBe(true);
|
||||
expect(items[1].children.map((c) => c.key)).toEqual([
|
||||
"assessments-overview",
|
||||
"upcoming",
|
||||
]);
|
||||
expect(items[1].children[0].label).toBe("Overview");
|
||||
});
|
||||
|
||||
it("reads page path from hash", () => {
|
||||
expect(getPagePathFromHash("#?page=/timetable")).toBe("/timetable");
|
||||
expect(getPagePathFromHash("#?page=/assessments/upcoming&x=1")).toBe(
|
||||
"/assessments/upcoming",
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores the custom Svelte sidebar list when parsing", () => {
|
||||
document.body.innerHTML = `
|
||||
<div id="menu">
|
||||
<ul class="logo-link">
|
||||
<li class="item" data-key="home" data-path="/home">
|
||||
<label>Home</label>
|
||||
</li>
|
||||
</ul>
|
||||
<ul id="bsplus-sidebar-root" class="logo-link bsplus-sidebar-list">
|
||||
<li class="item" data-key="fake" data-path="/fake">
|
||||
<label>Fake</label>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const menu = document.getElementById("menu")!;
|
||||
const items = parseNativeMenu(menu);
|
||||
|
||||
expect(items.map((i) => i.key)).toEqual(["home"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import type { SidebarItem } from "./types";
|
||||
|
||||
/** Native SEQTA list only — never the custom Svelte `#bsplus-sidebar-root`. */
|
||||
export function getNativeMenuList(menu: HTMLElement): HTMLElement | null {
|
||||
return (
|
||||
(menu.querySelector(
|
||||
":scope > ul:not(#bsplus-sidebar-root)",
|
||||
) as HTMLElement | null) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function readLabelText(label: HTMLElement | null): string {
|
||||
if (!label) return "";
|
||||
const clone = label.cloneNode(true) as HTMLElement;
|
||||
clone.querySelectorAll("svg").forEach((svg) => svg.remove());
|
||||
return (clone.textContent ?? "").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function readIconHtml(label: HTMLElement | null): string {
|
||||
if (!label) return "";
|
||||
const svg = label.querySelector(":scope > svg");
|
||||
return svg instanceof SVGElement ? svg.outerHTML : "";
|
||||
}
|
||||
|
||||
function parseEntry(entry: HTMLElement): SidebarItem | null {
|
||||
if (!entry.matches("li.item, section.item, li, section")) return null;
|
||||
|
||||
const label = entry.querySelector(":scope > label") as HTMLElement | null;
|
||||
const labelText = readLabelText(label);
|
||||
let key =
|
||||
entry.dataset.key ??
|
||||
entry.id ??
|
||||
labelText.toLowerCase().replace(/\s+/g, "-") ??
|
||||
"";
|
||||
|
||||
// Stable key for BS+ Overview injection (no data-key on the native node).
|
||||
if (
|
||||
!entry.dataset.key &&
|
||||
entry.classList.contains("betterseqta-assessments-overview-item")
|
||||
) {
|
||||
key = "assessments-overview";
|
||||
}
|
||||
|
||||
if (!key && !labelText) return null;
|
||||
|
||||
const childList = entry.querySelector(
|
||||
":scope > .sub > ul",
|
||||
) as HTMLElement | null;
|
||||
const children = childList
|
||||
? [...childList.children]
|
||||
.filter((node): node is HTMLElement => node instanceof HTMLElement)
|
||||
.map(parseEntry)
|
||||
.filter((item): item is SidebarItem => item != null)
|
||||
: [];
|
||||
|
||||
return {
|
||||
key: key || labelText,
|
||||
path: entry.dataset.path ?? null,
|
||||
id: entry.id || null,
|
||||
label: labelText,
|
||||
iconHtml: readIconHtml(label),
|
||||
hasChildren: entry.classList.contains("hasChildren") || children.length > 0,
|
||||
colour: entry.getAttribute("data-colour"),
|
||||
itemColour: entry.style.getPropertyValue("--item-colour") || null,
|
||||
betterseqta: entry.dataset.betterseqta === "true",
|
||||
children,
|
||||
};
|
||||
}
|
||||
|
||||
/** Parse the native SEQTA `#menu` list into a plain tree for the Svelte sidebar. */
|
||||
export function parseNativeMenu(menu: HTMLElement): SidebarItem[] {
|
||||
const list = getNativeMenuList(menu);
|
||||
if (!list) return [];
|
||||
|
||||
return [...list.children]
|
||||
.filter((node): node is HTMLElement => node instanceof HTMLElement)
|
||||
.map(parseEntry)
|
||||
.filter((item): item is SidebarItem => item != null);
|
||||
}
|
||||
|
||||
export function findNativeMenuEntry(
|
||||
menu: HTMLElement,
|
||||
item: Pick<SidebarItem, "key" | "id" | "path" | "label">,
|
||||
): HTMLElement | null {
|
||||
const list = getNativeMenuList(menu);
|
||||
if (!list) return null;
|
||||
|
||||
if (item.key === "assessments-overview") {
|
||||
const overview = list.querySelector(
|
||||
".betterseqta-assessments-overview-item",
|
||||
);
|
||||
if (overview instanceof HTMLElement) return overview;
|
||||
}
|
||||
|
||||
if (item.id) {
|
||||
const byId = list.querySelector(`#${CSS.escape(item.id)}`);
|
||||
if (byId instanceof HTMLElement) return byId;
|
||||
}
|
||||
|
||||
if (item.key) {
|
||||
const byKey = list.querySelector(
|
||||
`li[data-key="${CSS.escape(item.key)}"], section[data-key="${CSS.escape(item.key)}"]`,
|
||||
);
|
||||
if (byKey instanceof HTMLElement) return byKey;
|
||||
}
|
||||
|
||||
if (item.path) {
|
||||
const byPath = list.querySelector(
|
||||
`li[data-path="${CSS.escape(item.path)}"], section[data-path="${CSS.escape(item.path)}"]`,
|
||||
);
|
||||
if (byPath instanceof HTMLElement) return byPath;
|
||||
}
|
||||
|
||||
if (item.label) {
|
||||
const candidates = list.querySelectorAll<HTMLElement>(
|
||||
"li.item, section.item",
|
||||
);
|
||||
for (const candidate of candidates) {
|
||||
const label = candidate.querySelector(":scope > label");
|
||||
if (
|
||||
label instanceof HTMLElement &&
|
||||
readLabelText(label) === item.label
|
||||
) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getPagePathFromHash(hash = location.hash): string {
|
||||
const match = hash.match(/[?&]page=([^&]*)/);
|
||||
if (!match) return "";
|
||||
try {
|
||||
return decodeURIComponent(match[1]);
|
||||
} catch {
|
||||
return match[1];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||
import {
|
||||
findNativeMenuEntry,
|
||||
getNativeMenuList,
|
||||
getPagePathFromHash,
|
||||
parseNativeMenu,
|
||||
} from "./parseNativeMenu";
|
||||
import type { SidebarDrillFrame, SidebarItem } from "./types";
|
||||
|
||||
function orderItems(items: SidebarItem[], order: string[]): SidebarItem[] {
|
||||
if (!order.length) return items;
|
||||
|
||||
const byKey = new Map(items.map((item) => [item.key, item]));
|
||||
const seen = new Set<string>();
|
||||
const ordered: SidebarItem[] = [];
|
||||
|
||||
for (const key of order) {
|
||||
const item = byKey.get(key);
|
||||
if (!item) continue;
|
||||
ordered.push(item);
|
||||
seen.add(key);
|
||||
}
|
||||
|
||||
for (const item of items) {
|
||||
if (!seen.has(item.key)) ordered.push(item);
|
||||
}
|
||||
|
||||
return ordered;
|
||||
}
|
||||
|
||||
function filterVisible(items: SidebarItem[]): SidebarItem[] {
|
||||
const menuItems = settingsState.menuitems as Record<
|
||||
string,
|
||||
{ toggle?: boolean } | undefined
|
||||
>;
|
||||
|
||||
return items.filter((item) => menuItems[item.key]?.toggle !== false);
|
||||
}
|
||||
|
||||
/**
|
||||
* SEQTA (and some themes) strip `.active` from `#menu li` after navigation.
|
||||
* Theme decorations (e.g. beach palm/sand) and drill `.sub` chrome all depend
|
||||
* on that class staying on our custom list — re-apply it from known state.
|
||||
*/
|
||||
export function restoreCustomMenuActive() {
|
||||
const root = document.getElementById("bsplus-sidebar-root");
|
||||
if (!root) return;
|
||||
|
||||
// Open drill folders (must keep `.active` for `.sub` layout + theme selectors).
|
||||
for (const li of root.querySelectorAll("li.hasChildren")) {
|
||||
if (!(li instanceof HTMLElement)) continue;
|
||||
if (!li.querySelector(":scope > .sub")) continue;
|
||||
if (!li.classList.contains("active")) li.classList.add("active");
|
||||
}
|
||||
|
||||
// Route-active row — Svelte sets aria-current, but SEQTA often removes `.active`.
|
||||
const activeKey = sidebarState.activeKey;
|
||||
if (activeKey) {
|
||||
const activeLi = root.querySelector(
|
||||
`li.item[data-key="${CSS.escape(activeKey)}"]`,
|
||||
);
|
||||
if (
|
||||
activeLi instanceof HTMLElement &&
|
||||
!activeLi.classList.contains("active")
|
||||
) {
|
||||
activeLi.classList.add("active");
|
||||
}
|
||||
}
|
||||
|
||||
for (const li of root.querySelectorAll('li.item[aria-current="page"]')) {
|
||||
if (li instanceof HTMLElement && !li.classList.contains("active")) {
|
||||
li.classList.add("active");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @deprecated Use restoreCustomMenuActive */
|
||||
export const restoreCustomDrillActive = restoreCustomMenuActive;
|
||||
|
||||
/** Clear native drill state so it cannot steal pointer-events from the custom list. */
|
||||
export function clearNativeDrillActive(menu: HTMLElement) {
|
||||
const nativeList = getNativeMenuList(menu);
|
||||
nativeList
|
||||
?.querySelectorAll("li.hasChildren.active, section.hasChildren.active")
|
||||
.forEach((node) => node.classList.remove("active"));
|
||||
}
|
||||
|
||||
function findItemByPath(
|
||||
items: SidebarItem[],
|
||||
path: string,
|
||||
): SidebarItem | null {
|
||||
for (const item of items) {
|
||||
if (item.path === path) return item;
|
||||
if (item.children.length) {
|
||||
const nested = findItemByPath(item.children, path);
|
||||
if (nested) return nested;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findItemByKey(
|
||||
items: SidebarItem[],
|
||||
key: string,
|
||||
): SidebarItem | null {
|
||||
for (const item of items) {
|
||||
if (item.key === key) return item;
|
||||
if (item.children.length) {
|
||||
const nested = findItemByKey(item.children, key);
|
||||
if (nested) return nested;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
class SidebarState {
|
||||
items = $state.raw<SidebarItem[]>([]);
|
||||
drillStack = $state.raw<SidebarDrillFrame[]>([]);
|
||||
activeKey = $state<string | null>(null);
|
||||
activePath = $state("");
|
||||
editMode = $state(false);
|
||||
iconOnly = $state(false);
|
||||
ready = $state(false);
|
||||
|
||||
visibleRootItems = $derived(
|
||||
filterVisible(orderItems(this.items, settingsState.menuorder ?? [])),
|
||||
);
|
||||
|
||||
currentItems = $derived(
|
||||
this.drillStack.length > 0
|
||||
? this.drillStack[this.drillStack.length - 1].items
|
||||
: this.visibleRootItems,
|
||||
);
|
||||
|
||||
currentTitle = $derived(
|
||||
this.drillStack.length > 0
|
||||
? this.drillStack[this.drillStack.length - 1].label
|
||||
: null,
|
||||
);
|
||||
|
||||
isDrilling = $derived(this.drillStack.length > 0);
|
||||
|
||||
compact = $derived(this.iconOnly && !this.isDrilling && !this.editMode);
|
||||
|
||||
syncFromNative(menu: HTMLElement) {
|
||||
this.items = parseNativeMenu(menu);
|
||||
this.ready = this.items.length > 0;
|
||||
this.syncActiveFromLocation();
|
||||
this.pruneDrillStack();
|
||||
}
|
||||
|
||||
syncSettings() {
|
||||
this.iconOnly = settingsState.iconOnlySidebar === true;
|
||||
}
|
||||
|
||||
syncActiveFromLocation() {
|
||||
const path = getPagePathFromHash();
|
||||
this.activePath = path;
|
||||
|
||||
if (!path) {
|
||||
this.activeKey = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const exact = findItemByPath(this.items, path);
|
||||
if (exact) {
|
||||
this.activeKey = exact.key;
|
||||
return;
|
||||
}
|
||||
|
||||
// Nested routes like /assessments/upcoming → assessments
|
||||
const segments = path.split("/").filter(Boolean);
|
||||
while (segments.length > 1) {
|
||||
segments.pop();
|
||||
const parentPath = `/${segments.join("/")}`;
|
||||
const parent = findItemByPath(this.items, parentPath);
|
||||
if (parent) {
|
||||
this.activeKey = parent.key;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.activeKey = null;
|
||||
}
|
||||
|
||||
pruneDrillStack() {
|
||||
if (!this.drillStack.length) return;
|
||||
|
||||
const next: SidebarDrillFrame[] = [];
|
||||
let cursor = this.visibleRootItems;
|
||||
|
||||
for (const frame of this.drillStack) {
|
||||
const folder = cursor.find((item) => item.key === frame.key);
|
||||
if (!folder?.hasChildren) break;
|
||||
const children = filterVisible(folder.children);
|
||||
next.push({ key: folder.key, label: folder.label, items: children });
|
||||
cursor = children;
|
||||
}
|
||||
|
||||
this.drillStack = next;
|
||||
}
|
||||
|
||||
openFolder(item: SidebarItem) {
|
||||
if (!item.hasChildren) return;
|
||||
this.drillStack = [
|
||||
...this.drillStack,
|
||||
{
|
||||
key: item.key,
|
||||
label: item.label,
|
||||
items: filterVisible(item.children),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
goBack() {
|
||||
if (!this.drillStack.length) return;
|
||||
this.drillStack = this.drillStack.slice(0, -1);
|
||||
}
|
||||
|
||||
resetDrill() {
|
||||
this.drillStack = [];
|
||||
}
|
||||
|
||||
setEditMode(enabled: boolean) {
|
||||
this.editMode = enabled;
|
||||
if (enabled) this.resetDrill();
|
||||
}
|
||||
|
||||
reorderRoot(fromKey: string, toKey: string) {
|
||||
if (fromKey === toKey) return;
|
||||
const keys = this.visibleRootItems.map((item) => item.key);
|
||||
const from = keys.indexOf(fromKey);
|
||||
const to = keys.indexOf(toKey);
|
||||
if (from < 0 || to < 0) return;
|
||||
|
||||
const next = [...keys];
|
||||
const [moved] = next.splice(from, 1);
|
||||
next.splice(to, 0, moved);
|
||||
settingsState.menuorder = next;
|
||||
}
|
||||
|
||||
setItemVisibility(key: string, visible: boolean) {
|
||||
const current = {
|
||||
...(settingsState.menuitems as Record<string, { toggle: boolean }>),
|
||||
};
|
||||
current[key] = { toggle: visible };
|
||||
settingsState.menuitems = current as typeof settingsState.menuitems;
|
||||
}
|
||||
|
||||
restoreDefaultOrder() {
|
||||
const defaults = settingsState.defaultmenuorder;
|
||||
if (defaults?.length) {
|
||||
settingsState.menuorder = [...defaults];
|
||||
}
|
||||
|
||||
const restored: Record<string, { toggle: boolean }> = {};
|
||||
for (const item of this.items) {
|
||||
restored[item.key] = { toggle: true };
|
||||
}
|
||||
settingsState.menuitems = restored as typeof settingsState.menuitems;
|
||||
}
|
||||
|
||||
activateItem(item: SidebarItem, menu: HTMLElement) {
|
||||
if (this.editMode) return;
|
||||
|
||||
if (item.hasChildren) {
|
||||
this.openFolder(item);
|
||||
return;
|
||||
}
|
||||
|
||||
this.activeKey = item.key;
|
||||
if (item.path) this.activePath = item.path;
|
||||
|
||||
const native = findNativeMenuEntry(menu, item);
|
||||
if (native) {
|
||||
// Never strip `.active` from custom `#bsplus-sidebar-root` folders.
|
||||
clearNativeDrillActive(menu);
|
||||
native.click();
|
||||
// SEQTA re-opens native drill and strips `.active` after click — undo both.
|
||||
clearNativeDrillActive(menu);
|
||||
restoreCustomMenuActive();
|
||||
requestAnimationFrame(() => {
|
||||
clearNativeDrillActive(menu);
|
||||
restoreCustomMenuActive();
|
||||
});
|
||||
setTimeout(() => {
|
||||
clearNativeDrillActive(menu);
|
||||
restoreCustomMenuActive();
|
||||
}, 0);
|
||||
setTimeout(() => {
|
||||
clearNativeDrillActive(menu);
|
||||
restoreCustomMenuActive();
|
||||
}, 50);
|
||||
setTimeout(() => {
|
||||
clearNativeDrillActive(menu);
|
||||
restoreCustomMenuActive();
|
||||
}, 100);
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.path) {
|
||||
location.hash = `?page=${item.path}`;
|
||||
}
|
||||
}
|
||||
|
||||
findByKey(key: string) {
|
||||
return findItemByKey(this.items, key);
|
||||
}
|
||||
}
|
||||
|
||||
export const sidebarState = new SidebarState();
|
||||
@@ -0,0 +1,19 @@
|
||||
export type SidebarItem = {
|
||||
key: string;
|
||||
path: string | null;
|
||||
id: string | null;
|
||||
label: string;
|
||||
/** Outer HTML of the label's leading SVG (if any). */
|
||||
iconHtml: string;
|
||||
hasChildren: boolean;
|
||||
colour: string | null;
|
||||
itemColour: string | null;
|
||||
betterseqta: boolean;
|
||||
children: SidebarItem[];
|
||||
};
|
||||
|
||||
export type SidebarDrillFrame = {
|
||||
key: string;
|
||||
label: string;
|
||||
items: SidebarItem[];
|
||||
};
|
||||
@@ -1,11 +1,17 @@
|
||||
import type { SettingsState } from "@/types/storage";
|
||||
import { settingsState } from "../listeners/SettingsState";
|
||||
import { applyMenuItemVisibility } from "../menuItemVisibility";
|
||||
import { ensureAnalyticsMenuOrder } from "@/seqta/utils/sidebarMenuIcons";
|
||||
import { ensureAnalyticsMenuOrder } from "@/seqta/utils/Openers/analyticsMenuOrder";
|
||||
import {
|
||||
isMenuOptionsOpen,
|
||||
setMenuOptionsOpen,
|
||||
} from "@/seqta/utils/Openers/menuOptionsState";
|
||||
import { ChangeMenuItemPositions } from "@/seqta/utils/Openers/menuOrder";
|
||||
import stringToHTML from "../stringToHTML";
|
||||
import Sortable from "sortablejs";
|
||||
|
||||
export let MenuOptionsOpen = false;
|
||||
export { MenuOptionsOpen, setMenuOptionsOpen } from "@/seqta/utils/Openers/menuOptionsState";
|
||||
export { ChangeMenuItemPositions } from "@/seqta/utils/Openers/menuOrder";
|
||||
|
||||
function escapeHtmlAttr(value: string): string {
|
||||
return value
|
||||
@@ -190,14 +196,22 @@ function restoreMenuItemsFromEditMode(
|
||||
}
|
||||
|
||||
export function OpenMenuOptions() {
|
||||
if (MenuOptionsOpen) return;
|
||||
if (isMenuOptionsOpen()) return;
|
||||
|
||||
// Custom Svelte sidebar owns edit mode when mounted.
|
||||
if (document.getElementById("bsplus-sidebar-root")) {
|
||||
void import("@/seqta/ui/sidebar/mountCustomSidebar").then((mod) => {
|
||||
if (mod.openCustomSidebarEditor()) setMenuOptionsOpen(true);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const container = document.getElementById("container");
|
||||
const menu = document.getElementById("menu");
|
||||
if (!container || !menu) return;
|
||||
|
||||
syncDefaultMenuOrder(menu);
|
||||
MenuOptionsOpen = true;
|
||||
setMenuOptionsOpen(true);
|
||||
menu.classList.add("bsplus-sidebar-edit-mode");
|
||||
|
||||
const { cover, menusettings, defaultbutton, savebutton } =
|
||||
@@ -220,7 +234,7 @@ export function OpenMenuOptions() {
|
||||
const closeAll = () => {
|
||||
menusettings.remove();
|
||||
cover.remove();
|
||||
MenuOptionsOpen = false;
|
||||
setMenuOptionsOpen(false);
|
||||
menu.classList.remove("bsplus-sidebar-edit-mode");
|
||||
menu.style.setProperty("--menuHidden", "none");
|
||||
restoreMenuItemsFromEditMode(menu, listItems);
|
||||
@@ -267,34 +281,3 @@ function cloneAttributes(target: any, source: any) {
|
||||
target.setAttribute(attr.nodeName, attr.nodeValue);
|
||||
});
|
||||
}
|
||||
|
||||
export function ChangeMenuItemPositions(menuorder: SettingsState["menuorder"]) {
|
||||
var menuList = document.querySelector("#menu")!.firstChild!.childNodes;
|
||||
|
||||
let listorder = [];
|
||||
for (let i = 0; i < menuList.length; i++) {
|
||||
const menu = menuList[i] as HTMLElement;
|
||||
|
||||
let a = menuorder.indexOf(menu.dataset.key);
|
||||
|
||||
listorder.push(a);
|
||||
}
|
||||
|
||||
var newArr = [];
|
||||
for (var i = 0; i < listorder.length; i++) {
|
||||
const index = listorder[i];
|
||||
if (index >= 0) {
|
||||
newArr[index] = menuList[i];
|
||||
}
|
||||
}
|
||||
|
||||
let listItemsDOM = document.getElementById("menu")!.firstChild;
|
||||
for (let i = 0; i < newArr.length; i++) {
|
||||
const element = newArr[i];
|
||||
if (element) {
|
||||
const elem = element as HTMLElement;
|
||||
elem.setAttribute("data-checked", "true");
|
||||
listItemsDOM!.appendChild(element);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||
|
||||
/** Insert a menu DOM node after the top-level item with `afterKey`. */
|
||||
export function insertMenuItemAfterKey(
|
||||
menuList: HTMLElement,
|
||||
item: HTMLElement,
|
||||
afterKey: string,
|
||||
): void {
|
||||
const after = menuList.querySelector(
|
||||
`:scope > li[data-key="${afterKey}"], :scope > section[data-key="${afterKey}"]`,
|
||||
);
|
||||
if (after instanceof HTMLElement) {
|
||||
after.insertAdjacentElement("afterend", item);
|
||||
} else {
|
||||
menuList.appendChild(item);
|
||||
}
|
||||
}
|
||||
|
||||
export function insertKeyAfterInOrder(
|
||||
order: string[],
|
||||
key: string,
|
||||
afterKey: string,
|
||||
): string[] {
|
||||
if (order.includes(key)) return order;
|
||||
const copy = [...order];
|
||||
const afterIdx = copy.indexOf(afterKey);
|
||||
if (afterIdx >= 0) {
|
||||
copy.splice(afterIdx + 1, 0, key);
|
||||
} else {
|
||||
copy.push(key);
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
/** Default Analytics immediately below Courses in saved menu order. */
|
||||
export function ensureAnalyticsMenuOrder(): void {
|
||||
for (const key of ["defaultmenuorder", "menuorder"] as const) {
|
||||
const order = settingsState[key];
|
||||
if (key === "menuorder" && order.length === 0) continue;
|
||||
if (!order.includes("analytics")) {
|
||||
settingsState[key] = insertKeyAfterInOrder(order, "analytics", "courses");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Tiny flag module with no imports — breaks the OpenMenuOptions ↔ sidebarMenuIcons
|
||||
* cycle that caused Vite HMR: "does not provide an export named 'MenuOptionsOpen'".
|
||||
*/
|
||||
export let MenuOptionsOpen = false;
|
||||
|
||||
export function setMenuOptionsOpen(open: boolean) {
|
||||
MenuOptionsOpen = open;
|
||||
}
|
||||
|
||||
export function isMenuOptionsOpen(): boolean {
|
||||
return MenuOptionsOpen;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { SettingsState } from "@/types/storage";
|
||||
|
||||
/** Reorder `#menu > ul` children to match a saved `menuorder` key list. */
|
||||
export function ChangeMenuItemPositions(menuorder: SettingsState["menuorder"]) {
|
||||
const menuRoot = document.querySelector("#menu")?.firstChild;
|
||||
if (!menuRoot) return;
|
||||
|
||||
const menuList = menuRoot.childNodes;
|
||||
const listorder: number[] = [];
|
||||
|
||||
for (let i = 0; i < menuList.length; i++) {
|
||||
const menu = menuList[i] as HTMLElement;
|
||||
listorder.push(menuorder.indexOf(menu.dataset.key));
|
||||
}
|
||||
|
||||
const newArr: ChildNode[] = [];
|
||||
for (let i = 0; i < listorder.length; i++) {
|
||||
const index = listorder[i];
|
||||
if (index >= 0) {
|
||||
newArr[index] = menuList[i];
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < newArr.length; i++) {
|
||||
const element = newArr[i];
|
||||
if (element) {
|
||||
const elem = element as HTMLElement;
|
||||
elem.setAttribute("data-checked", "true");
|
||||
menuRoot.appendChild(element);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,8 @@
|
||||
import browser from "webextension-polyfill";
|
||||
|
||||
import { closeExtensionPopup } from "@/seqta/utils/Closers/closeExtensionPopup";
|
||||
import {
|
||||
MenuOptionsOpen,
|
||||
OpenMenuOptions,
|
||||
} from "@/seqta/utils/Openers/OpenMenuOptions";
|
||||
import { OpenMenuOptions } from "@/seqta/utils/Openers/OpenMenuOptions";
|
||||
import { isMenuOptionsOpen } from "@/seqta/utils/Openers/menuOptionsState";
|
||||
|
||||
import sendThemeUpdate from "@/seqta/utils/sendThemeUpdate";
|
||||
import hideSensitiveContent from "@/seqta/ui/dev/hideSensitiveContent";
|
||||
@@ -144,7 +142,7 @@ export class MessageHandler {
|
||||
}
|
||||
|
||||
editSidebar() {
|
||||
if (!MenuOptionsOpen) {
|
||||
if (!isMenuOptionsOpen()) {
|
||||
OpenMenuOptions();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { renderShortcuts } from "@/seqta/utils/Render/renderShortcuts";
|
||||
import { FilterUpcomingAssessments } from "@/seqta/utils/FilterUpcomingAssessments";
|
||||
import { registerHomeUpcomingSettingsListeners } from "@/seqta/utils/Loaders/LoadHomePage";
|
||||
import { applyMenuItemVisibility } from "@/seqta/utils/menuItemVisibility";
|
||||
import { ChangeMenuItemPositions } from "@/seqta/utils/Openers/OpenMenuOptions";
|
||||
import { ChangeMenuItemPositions } from "@/seqta/utils/Openers/menuOrder";
|
||||
|
||||
import browser from "webextension-polyfill";
|
||||
import type { CustomShortcut } from "@/types/storage";
|
||||
|
||||
@@ -10,7 +10,9 @@ export function applyMenuItemVisibility(): void {
|
||||
let css = "";
|
||||
for (const [menuItem, config] of Object.entries(settingsState.menuitems ?? {})) {
|
||||
if (config && !config.toggle) {
|
||||
css += `li[data-key=${menuItem}],section[data-key=${menuItem}]{display:var(--menuHidden) !important;transition:1s;}`;
|
||||
// Scope to the native SEQTA list only — the custom Svelte sidebar
|
||||
// filters visibility in JS and must not be forced hidden by this rule.
|
||||
css += `#menu > ul:not(#bsplus-sidebar-root) li[data-key=${menuItem}],#menu > ul:not(#bsplus-sidebar-root) section[data-key=${menuItem}]{display:var(--menuHidden) !important;transition:1s;}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import MenuitemSVGKey from "@/seqta/content/MenuItemSVGKey.json";
|
||||
import {
|
||||
ChangeMenuItemPositions,
|
||||
MenuOptionsOpen,
|
||||
} from "@/seqta/utils/Openers/OpenMenuOptions";
|
||||
import { ChangeMenuItemPositions } from "@/seqta/utils/Openers/menuOrder";
|
||||
import { isMenuOptionsOpen } from "@/seqta/utils/Openers/menuOptionsState";
|
||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||
import stringToHTML from "@/seqta/utils/stringToHTML";
|
||||
import { waitForEngageMenuList } from "@/seqta/utils/waitForEngageMenuList";
|
||||
@@ -12,48 +10,6 @@ import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
|
||||
|
||||
const BETTERSEQTA_ICON_ATTR = "data-betterseqta-icon";
|
||||
|
||||
export function insertMenuItemAfterKey(
|
||||
menuList: HTMLElement,
|
||||
item: HTMLElement,
|
||||
afterKey: string,
|
||||
): void {
|
||||
const after = menuList.querySelector(
|
||||
`:scope > li[data-key="${afterKey}"], :scope > section[data-key="${afterKey}"]`,
|
||||
);
|
||||
if (after instanceof HTMLElement) {
|
||||
after.insertAdjacentElement("afterend", item);
|
||||
} else {
|
||||
menuList.appendChild(item);
|
||||
}
|
||||
}
|
||||
|
||||
export function insertKeyAfterInOrder(
|
||||
order: string[],
|
||||
key: string,
|
||||
afterKey: string,
|
||||
): string[] {
|
||||
if (order.includes(key)) return order;
|
||||
const copy = [...order];
|
||||
const afterIdx = copy.indexOf(afterKey);
|
||||
if (afterIdx >= 0) {
|
||||
copy.splice(afterIdx + 1, 0, key);
|
||||
} else {
|
||||
copy.push(key);
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
/** Default Analytics immediately below Courses in saved menu order. */
|
||||
export function ensureAnalyticsMenuOrder(): void {
|
||||
for (const key of ["defaultmenuorder", "menuorder"] as const) {
|
||||
const order = settingsState[key];
|
||||
if (key === "menuorder" && order.length === 0) continue;
|
||||
if (!order.includes("analytics")) {
|
||||
settingsState[key] = insertKeyAfterInOrder(order, "analytics", "courses");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getMenuLabel(element: HTMLElement): HTMLElement | null {
|
||||
const label = element.querySelector(":scope > label");
|
||||
return label instanceof HTMLElement ? label : null;
|
||||
@@ -107,7 +63,7 @@ export function replaceMenuSVG(element: HTMLElement, svg: string) {
|
||||
}
|
||||
|
||||
export function processMenuItemNode(node: HTMLElement) {
|
||||
if (!isTopLevelSidebarItem(node) || MenuOptionsOpen) return;
|
||||
if (!isTopLevelSidebarItem(node) || isMenuOptionsOpen()) return;
|
||||
|
||||
const key = node.dataset.key as keyof typeof MenuitemSVGKey | undefined;
|
||||
if (key && MenuitemSVGKey[key]) {
|
||||
@@ -119,7 +75,7 @@ export function processMenuItemNode(node: HTMLElement) {
|
||||
}
|
||||
|
||||
function processTopLevelMenuItems(reorder = !isSeqtaEngageExperience()) {
|
||||
if (MenuOptionsOpen) return;
|
||||
if (isMenuOptionsOpen()) return;
|
||||
|
||||
const topList = getTopLevelMenuList();
|
||||
if (!topList) return;
|
||||
@@ -186,7 +142,7 @@ export async function observeMenuItemPosition() {
|
||||
if (!isTopLevelSidebarItem(node)) return;
|
||||
if ((element as any)[processedSymbol]) return;
|
||||
|
||||
if (!MenuOptionsOpen) {
|
||||
if (!isMenuOptionsOpen()) {
|
||||
processMenuItemNode(node);
|
||||
ChangeMenuItemPositions(settingsState.menuorder);
|
||||
(element as any)[processedSymbol] = true;
|
||||
|
||||
Reference in New Issue
Block a user