diff --git a/src/plugins/monofile.ts b/src/plugins/monofile.ts
index c1a45a6c..c74d39ea 100644
--- a/src/plugins/monofile.ts
+++ b/src/plugins/monofile.ts
@@ -3,10 +3,6 @@ import browser from "webextension-polyfill";
import { animate, stagger } from "motion";
// Internal utilities and functions
-import {
- ChangeMenuItemPositions,
- MenuOptionsOpen,
-} from "@/seqta/utils/Openers/OpenMenuOptions";
import { GetThresholdOfColor } from "@/seqta/ui/colors/getThresholdColour";
import { waitForElm } from "@/seqta/utils/waitForElm";
import { delay } from "@/seqta/utils/delay";
@@ -34,7 +30,7 @@ import { runStartupPopupQueue } from "@/seqta/utils/Openers/StartupPopupQueue";
import { updateTimetableTimes } from "@/seqta/utils/updateTimetableTimes";
// JSON content
-import MenuitemSVGKey from "@/seqta/content/MenuItemSVGKey.json";
+import { observeMenuItemPosition } from "@/seqta/utils/sidebarMenuIcons";
// Icons and fonts
import IconFamily from "@/resources/fonts/IconFamily.woff";
@@ -612,75 +608,6 @@ export function tryLoad() {
);
}
-function ReplaceMenuSVG(element: HTMLElement, svg: string) {
- let item = element.firstChild as HTMLElement;
- item!.firstChild!.remove();
-
- item.innerHTML = `${item.innerHTML}`;
-
- let newsvg = stringToHTML(svg).firstChild;
- item.insertBefore(newsvg as Node, item.firstChild);
-}
-
-const processedSymbol = Symbol("processed");
-
-export async function ObserveMenuItemPosition() {
- if (isSeqtaEngageExperience()) return;
- await waitForElm("#menu > ul > li");
-
- eventManager.register(
- "menuList",
- {
- parentElement: document.querySelector("#menu")!.firstChild as Element,
- },
- (element: Element) => {
- const node = element as HTMLElement;
-
- // Only process top-level menu items and skip everything else
- if (
- !node.classList.contains("item") ||
- node.nodeName !== "LI" ||
- node.parentElement?.parentElement?.id !== "menu"
- ) {
- return;
- }
-
- // Early exit if already processed
- if ((element as any)[processedSymbol]) {
- return;
- }
-
- if (!MenuOptionsOpen) {
- const key =
- MenuitemSVGKey[node?.dataset?.key! as keyof typeof MenuitemSVGKey];
- if (key) {
- ReplaceMenuSVG(
- node,
- MenuitemSVGKey[node.dataset.key as keyof typeof MenuitemSVGKey],
- );
- } else if (node?.firstChild?.nodeName === "LABEL") {
- const label = node.firstChild as HTMLElement;
- let textNode = label.lastChild as HTMLElement;
-
- if (
- textNode.nodeType === 3 &&
- textNode.parentNode &&
- textNode.parentNode.nodeName !== "SPAN"
- ) {
- const span = document.createElement("span");
- span.textContent = textNode.nodeValue;
-
- label.replaceChild(span, textNode);
- }
- }
- ChangeMenuItemPositions(settingsState.menuorder);
-
- (element as any)[processedSymbol] = true;
- }
- },
- );
-}
-
export function showConflictPopup() {
if (document.getElementById("conflict-popup")) return;
document.body.classList.remove("hidden");
@@ -760,7 +687,7 @@ export function init() {
}
document.querySelector(".legacy-root")?.classList.add("hidden");
- ObserveMenuItemPosition();
+ void observeMenuItemPosition();
new StorageChangeHandler();
new MessageHandler();
diff --git a/src/seqta/ui/AddBetterSEQTAElements.ts b/src/seqta/ui/AddBetterSEQTAElements.ts
index dd512c99..644f33c1 100644
--- a/src/seqta/ui/AddBetterSEQTAElements.ts
+++ b/src/seqta/ui/AddBetterSEQTAElements.ts
@@ -1,3 +1,4 @@
+import { waitForEngageMenuList } from "@/seqta/utils/waitForEngageMenuList";
import { addExtensionSettings } from "@/seqta/utils/Adders/AddExtensionSettings";
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
import { loadEngageHomePage } from "@/seqta/utils/Loaders/LoadEngageHomePage";
@@ -287,44 +288,6 @@ async function createSettingsButton(parent?: Element) {
);
}
-/** Engage mounts the sidebar inside batched React trees; EventManager-based waitForElm can miss `#menu`. Polling `waitForElm` matches the real DOM reliably. */
-async function waitForEngageMenuList(): Promise {
- const poll = true as const;
- const interval = 100;
- const trySelectors: { selector: string; maxIterations: number }[] = [
- { selector: "#menu > ul > li", maxIterations: 500 },
- { selector: "#menu ul", maxIterations: 350 },
- { selector: "#menu", maxIterations: 350 },
- ];
-
- for (const { selector, maxIterations } of trySelectors) {
- try {
- await waitForElm(selector, poll, interval, maxIterations);
- } catch {
- continue;
- }
-
- if (selector === "#menu > ul > li") {
- const ul = document.querySelector("#menu > ul") as HTMLElement | null;
- if (ul) return ul;
- } else if (selector === "#menu ul") {
- const ul = document.querySelector("#menu ul") as HTMLElement | null;
- if (ul) return ul;
- } else {
- const menu = document.getElementById("menu");
- const ul =
- (menu?.querySelector("ul") as HTMLElement | null) ??
- (menu?.firstElementChild as HTMLElement | null);
- if (ul) return ul;
- }
- }
-
- console.warn(
- "[BetterSEQTA+] Engage: could not find a menu list to inject the home button",
- );
- return null;
-}
-
async function injectEngageHomeButton() {
if (document.getElementById("homebutton")) return;
diff --git a/src/seqta/utils/sidebarMenuIcons.ts b/src/seqta/utils/sidebarMenuIcons.ts
new file mode 100644
index 00000000..48b49875
--- /dev/null
+++ b/src/seqta/utils/sidebarMenuIcons.ts
@@ -0,0 +1,153 @@
+import MenuitemSVGKey from "@/seqta/content/MenuItemSVGKey.json";
+import {
+ ChangeMenuItemPositions,
+ MenuOptionsOpen,
+} from "@/seqta/utils/Openers/OpenMenuOptions";
+import { settingsState } from "@/seqta/utils/listeners/SettingsState";
+import stringToHTML from "@/seqta/utils/stringToHTML";
+import { waitForEngageMenuList } from "@/seqta/utils/waitForEngageMenuList";
+import { waitForElm } from "@/seqta/utils/waitForElm";
+import { eventManager } from "@/seqta/utils/listeners/EventManager";
+import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
+
+const BETTERSEQTA_ICON_ATTR = "data-betterseqta-icon";
+
+function getMenuLabel(element: HTMLElement): HTMLElement | null {
+ const label = element.querySelector(":scope > label");
+ return label instanceof HTMLElement ? label : null;
+}
+
+function getTopLevelMenuList(menu = document.getElementById("menu")): HTMLElement | null {
+ if (!menu) return null;
+ return (
+ (menu.querySelector(":scope > ul") as HTMLElement | null) ??
+ (menu.querySelector("ul") as HTMLElement | null)
+ );
+}
+
+export function isTopLevelSidebarItem(node: HTMLElement): boolean {
+ if (!node.classList.contains("item")) return false;
+ if (node.nodeName !== "LI" && node.nodeName !== "SECTION") return false;
+
+ const topList = getTopLevelMenuList();
+ return !!topList && node.parentElement === topList;
+}
+
+function wrapMenuLabelText(label: HTMLElement) {
+ const textNode = label.lastChild;
+ if (
+ textNode?.nodeType === 3 &&
+ textNode.parentNode &&
+ textNode.parentNode.nodeName !== "SPAN"
+ ) {
+ const span = document.createElement("span");
+ span.textContent = textNode.nodeValue;
+ label.replaceChild(span, textNode);
+ }
+}
+
+export function replaceMenuSVG(element: HTMLElement, svg: string) {
+ const label = getMenuLabel(element);
+ if (!label?.firstChild) return;
+
+ if (label.firstElementChild?.getAttribute(BETTERSEQTA_ICON_ATTR) === "true") {
+ return;
+ }
+
+ label.firstChild.remove();
+ label.innerHTML = `${label.innerHTML}`;
+
+ const newSvg = stringToHTML(svg).firstChild;
+ if (!(newSvg instanceof Element)) return;
+
+ newSvg.setAttribute(BETTERSEQTA_ICON_ATTR, "true");
+ label.insertBefore(newSvg, label.firstChild);
+}
+
+export function processMenuItemNode(node: HTMLElement) {
+ if (!isTopLevelSidebarItem(node) || MenuOptionsOpen) return;
+
+ const key = node.dataset.key as keyof typeof MenuitemSVGKey | undefined;
+ if (key && MenuitemSVGKey[key]) {
+ replaceMenuSVG(node, MenuitemSVGKey[key]);
+ } else {
+ const label = getMenuLabel(node);
+ if (label) wrapMenuLabelText(label);
+ }
+}
+
+function processTopLevelMenuItems(reorder = !isSeqtaEngageExperience()) {
+ if (MenuOptionsOpen) return;
+
+ const topList = getTopLevelMenuList();
+ if (!topList) return;
+
+ for (const child of topList.children) {
+ if (child instanceof HTMLElement) {
+ processMenuItemNode(child);
+ }
+ }
+
+ if (reorder) {
+ ChangeMenuItemPositions(settingsState.menuorder);
+ }
+}
+
+let engageMenuIconObserver: MutationObserver | null = null;
+let engageMenuIconFrame: number | null = null;
+
+function scheduleEngageMenuIconPass() {
+ if (engageMenuIconFrame !== null) return;
+
+ engageMenuIconFrame = window.requestAnimationFrame(() => {
+ engageMenuIconFrame = null;
+ processTopLevelMenuItems(false);
+ });
+}
+
+async function observeEngageMenuIcons() {
+ const menuList = await waitForEngageMenuList();
+ const menu = document.getElementById("menu");
+ if (!menu || !menuList) return;
+
+ processTopLevelMenuItems(false);
+
+ engageMenuIconObserver?.disconnect();
+ engageMenuIconObserver = new MutationObserver(() => {
+ scheduleEngageMenuIconPass();
+ });
+ engageMenuIconObserver.observe(menu, {
+ childList: true,
+ subtree: true,
+ });
+}
+
+const processedSymbol = Symbol("processed");
+
+export async function observeMenuItemPosition() {
+ if (isSeqtaEngageExperience()) {
+ await observeEngageMenuIcons();
+ return;
+ }
+
+ await waitForElm("#menu > ul > li");
+
+ eventManager.register(
+ "menuList",
+ {
+ parentElement: document.querySelector("#menu")!.firstChild as Element,
+ },
+ (element: Element) => {
+ const node = element as HTMLElement;
+
+ if (!isTopLevelSidebarItem(node)) return;
+ if ((element as any)[processedSymbol]) return;
+
+ if (!MenuOptionsOpen) {
+ processMenuItemNode(node);
+ ChangeMenuItemPositions(settingsState.menuorder);
+ (element as any)[processedSymbol] = true;
+ }
+ },
+ );
+}
diff --git a/src/seqta/utils/waitForEngageMenuList.ts b/src/seqta/utils/waitForEngageMenuList.ts
new file mode 100644
index 00000000..0c9e6951
--- /dev/null
+++ b/src/seqta/utils/waitForEngageMenuList.ts
@@ -0,0 +1,39 @@
+import { waitForElm } from "@/seqta/utils/waitForElm";
+
+/** Engage mounts the sidebar inside batched React trees; polling `waitForElm` matches the real DOM reliably. */
+export async function waitForEngageMenuList(): Promise {
+ const poll = true as const;
+ const interval = 100;
+ const trySelectors: { selector: string; maxIterations: number }[] = [
+ { selector: "#menu > ul > li", maxIterations: 500 },
+ { selector: "#menu ul", maxIterations: 350 },
+ { selector: "#menu", maxIterations: 350 },
+ ];
+
+ for (const { selector, maxIterations } of trySelectors) {
+ try {
+ await waitForElm(selector, poll, interval, maxIterations);
+ } catch {
+ continue;
+ }
+
+ if (selector === "#menu > ul > li") {
+ const ul = document.querySelector("#menu > ul") as HTMLElement | null;
+ if (ul) return ul;
+ } else if (selector === "#menu ul") {
+ const ul = document.querySelector("#menu ul") as HTMLElement | null;
+ if (ul) return ul;
+ } else {
+ const menu = document.getElementById("menu");
+ const ul =
+ (menu?.querySelector("ul") as HTMLElement | null) ??
+ (menu?.firstElementChild as HTMLElement | null);
+ if (ul) return ul;
+ }
+ }
+
+ console.warn(
+ "[BetterSEQTA+] Engage: could not find a menu list to inject the home button",
+ );
+ return null;
+}