From 475b86500059e4c7327bb9d111741183ca488f26 Mon Sep 17 00:00:00 2001 From: Aden Linday Date: Sun, 24 May 2026 17:11:47 +0930 Subject: [PATCH] feat: apply our exisitng icons to engage sidebar --- src/plugins/monofile.ts | 77 +----------- src/seqta/ui/AddBetterSEQTAElements.ts | 39 +----- src/seqta/utils/sidebarMenuIcons.ts | 153 +++++++++++++++++++++++ src/seqta/utils/waitForEngageMenuList.ts | 39 ++++++ 4 files changed, 195 insertions(+), 113 deletions(-) create mode 100644 src/seqta/utils/sidebarMenuIcons.ts create mode 100644 src/seqta/utils/waitForEngageMenuList.ts 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; +}