diff --git a/src/SEQTA.ts b/src/SEQTA.ts index 6d4c5394..a7242c05 100644 --- a/src/SEQTA.ts +++ b/src/SEQTA.ts @@ -37,8 +37,6 @@ function registerFetchSeqtaAppLinkListener() { }); } -export let MenuOptionsOpen = false; - var IsSEQTAPage = false; let hasSEQTAText = false; diff --git a/src/css/injected.scss b/src/css/injected.scss index d0efd9a8..dccad4a4 100644 --- a/src/css/injected.scss +++ b/src/css/injected.scss @@ -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; diff --git a/src/plugins/built-in/assessmentsOverview/index.ts b/src/plugins/built-in/assessmentsOverview/index.ts index 68e2546e..0daf28d8 100644 --- a/src/plugins/built-in/assessmentsOverview/index.ts +++ b/src/plugins/built-in/assessmentsOverview/index.ts @@ -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 }); diff --git a/src/plugins/built-in/gradeAnalytics/core/index.ts b/src/plugins/built-in/gradeAnalytics/core/index.ts index 9d32c241..e69de29b 100644 --- a/src/plugins/built-in/gradeAnalytics/core/index.ts +++ b/src/plugins/built-in/gradeAnalytics/core/index.ts @@ -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 = ``; - - 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; diff --git a/src/plugins/built-in/gradeAnalytics/injectAnalyticsMenuItem.ts b/src/plugins/built-in/gradeAnalytics/injectAnalyticsMenuItem.ts new file mode 100644 index 00000000..152b80d3 --- /dev/null +++ b/src/plugins/built-in/gradeAnalytics/injectAnalyticsMenuItem.ts @@ -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 = ``; + + 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(); + }; +} diff --git a/src/plugins/built-in/gradeAnalytics/lazy.ts b/src/plugins/built-in/gradeAnalytics/lazy.ts index 125db7d5..6e21f903 100644 --- a/src/plugins/built-in/gradeAnalytics/lazy.ts +++ b/src/plugins/built-in/gradeAnalytics/lazy.ts @@ -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; diff --git a/src/plugins/core/manager.ts b/src/plugins/core/manager.ts index 5ea44033..0719975f 100644 --- a/src/plugins/core/manager.ts +++ b/src/plugins/core/manager.ts @@ -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"], ]; /** diff --git a/src/plugins/monofile.ts b/src/plugins/monofile.ts index cd98fa70..5e12c330 100644 --- a/src/plugins/monofile.ts +++ b/src/plugins/monofile.ts @@ -188,6 +188,9 @@ function SortMessagePageItems(messagesParentElement: any) { async function LoadPageElements(): Promise { 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(); diff --git a/src/seqta/ui/AddBetterSEQTAElements.ts b/src/seqta/ui/AddBetterSEQTAElements.ts index c4bf9959..b2774fd9 100644 --- a/src/seqta/ui/AddBetterSEQTAElements.ts +++ b/src/seqta/ui/AddBetterSEQTAElements.ts @@ -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( diff --git a/src/seqta/ui/sidebar/Sidebar.svelte b/src/seqta/ui/sidebar/Sidebar.svelte new file mode 100644 index 00000000..bd91bf9a --- /dev/null +++ b/src/seqta/ui/sidebar/Sidebar.svelte @@ -0,0 +1,371 @@ + + + + + +{#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} +
  • + + +
    + +
      + {#if isLast} + {#each current.frame.items as item (item.key)} + + {/each} + {:else} + + {#each current.frame.items as item (item.key)} + {#if item.key === nextKey} + {@render drillLevel(frames, depth + 1)} + {:else} + + {/if} + {/each} + {/if} +
    +
    +
  • +{/snippet} + + diff --git a/src/seqta/ui/sidebar/SidebarItem.svelte b/src/seqta/ui/sidebar/SidebarItem.svelte new file mode 100644 index 00000000..4c978bcb --- /dev/null +++ b/src/seqta/ui/sidebar/SidebarItem.svelte @@ -0,0 +1,236 @@ + + + + +
  • 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); + } + }} +> + + + {#if editMode} + + {/if} + {#if item.itemColour && !item.hasChildren} + + {/if} +
  • + + diff --git a/src/seqta/ui/sidebar/index.ts b/src/seqta/ui/sidebar/index.ts new file mode 100644 index 00000000..a6dbcb86 --- /dev/null +++ b/src/seqta/ui/sidebar/index.ts @@ -0,0 +1,10 @@ +export { + prepareCustomSidebarEarly, + mountCustomSidebar, + unmountCustomSidebar, + openCustomSidebarEditor, + closeCustomSidebarEditor, + isCustomSidebarMounted, +} from "./mountCustomSidebar"; +export { sidebarState } from "./sidebarState.svelte"; +export type { SidebarItem, SidebarDrillFrame } from "./types"; diff --git a/src/seqta/ui/sidebar/mountCustomSidebar.ts b/src/seqta/ui/sidebar/mountCustomSidebar.ts new file mode 100644 index 00000000..a7483759 --- /dev/null +++ b/src/seqta/ui/sidebar/mountCustomSidebar.ts @@ -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 | null = null; +let menuEl: HTMLElement | null = null; +let menuObserver: MutationObserver | null = null; +let syncTimer: ReturnType | null = null; +let hashListenerAttached = false; +let earlyPrepareStarted = false; +let catchupTimer: ReturnType | 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 { + 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(); +} diff --git a/src/seqta/ui/sidebar/parseNativeMenu.test.ts b/src/seqta/ui/sidebar/parseNativeMenu.test.ts new file mode 100644 index 00000000..45f1ba3c --- /dev/null +++ b/src/seqta/ui/sidebar/parseNativeMenu.test.ts @@ -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 = ` + + `; + + 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 = ` + + `; + + const menu = document.getElementById("menu")!; + const items = parseNativeMenu(menu); + + expect(items.map((i) => i.key)).toEqual(["home"]); + }); +}); diff --git a/src/seqta/ui/sidebar/parseNativeMenu.ts b/src/seqta/ui/sidebar/parseNativeMenu.ts new file mode 100644 index 00000000..76f4010b --- /dev/null +++ b/src/seqta/ui/sidebar/parseNativeMenu.ts @@ -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, +): 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( + "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]; + } +} diff --git a/src/seqta/ui/sidebar/sidebarState.svelte.ts b/src/seqta/ui/sidebar/sidebarState.svelte.ts new file mode 100644 index 00000000..97688147 --- /dev/null +++ b/src/seqta/ui/sidebar/sidebarState.svelte.ts @@ -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(); + 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([]); + drillStack = $state.raw([]); + activeKey = $state(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), + }; + 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 = {}; + 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(); diff --git a/src/seqta/ui/sidebar/types.ts b/src/seqta/ui/sidebar/types.ts new file mode 100644 index 00000000..2e89e10e --- /dev/null +++ b/src/seqta/ui/sidebar/types.ts @@ -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[]; +}; diff --git a/src/seqta/utils/Openers/OpenMenuOptions.ts b/src/seqta/utils/Openers/OpenMenuOptions.ts index 794fcec2..7622fff5 100644 --- a/src/seqta/utils/Openers/OpenMenuOptions.ts +++ b/src/seqta/utils/Openers/OpenMenuOptions.ts @@ -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); - } - } -} diff --git a/src/seqta/utils/Openers/analyticsMenuOrder.ts b/src/seqta/utils/Openers/analyticsMenuOrder.ts new file mode 100644 index 00000000..8ce46f8e --- /dev/null +++ b/src/seqta/utils/Openers/analyticsMenuOrder.ts @@ -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"); + } + } +} diff --git a/src/seqta/utils/Openers/menuOptionsState.ts b/src/seqta/utils/Openers/menuOptionsState.ts new file mode 100644 index 00000000..ee79163d --- /dev/null +++ b/src/seqta/utils/Openers/menuOptionsState.ts @@ -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; +} diff --git a/src/seqta/utils/Openers/menuOrder.ts b/src/seqta/utils/Openers/menuOrder.ts new file mode 100644 index 00000000..09e66680 --- /dev/null +++ b/src/seqta/utils/Openers/menuOrder.ts @@ -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); + } + } +} diff --git a/src/seqta/utils/listeners/MessageListener.ts b/src/seqta/utils/listeners/MessageListener.ts index c79e0ff8..73b6d21f 100644 --- a/src/seqta/utils/listeners/MessageListener.ts +++ b/src/seqta/utils/listeners/MessageListener.ts @@ -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(); } } diff --git a/src/seqta/utils/listeners/StorageChanges.ts b/src/seqta/utils/listeners/StorageChanges.ts index 74247b0c..720601ff 100644 --- a/src/seqta/utils/listeners/StorageChanges.ts +++ b/src/seqta/utils/listeners/StorageChanges.ts @@ -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"; diff --git a/src/seqta/utils/menuItemVisibility.ts b/src/seqta/utils/menuItemVisibility.ts index 62cd0386..9a8f251f 100644 --- a/src/seqta/utils/menuItemVisibility.ts +++ b/src/seqta/utils/menuItemVisibility.ts @@ -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;}`; } } diff --git a/src/seqta/utils/sidebarMenuIcons.ts b/src/seqta/utils/sidebarMenuIcons.ts index cc0c01c0..0ece53fd 100644 --- a/src/seqta/utils/sidebarMenuIcons.ts +++ b/src/seqta/utils/sidebarMenuIcons.ts @@ -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;