From 81a72061ca820408a8ba0edfdf43991f8b47fd03 Mon Sep 17 00:00:00 2001 From: sethburkart123 Date: Tue, 18 Jun 2024 12:32:43 +1000 Subject: [PATCH] perf: create eventManager with more efficient mutation observation --- src/SEQTA.ts | 329 +++++++++++----------- src/seqta/utils/listeners/EventManager.ts | 142 ++++++++++ 2 files changed, 302 insertions(+), 169 deletions(-) create mode 100644 src/seqta/utils/listeners/EventManager.ts diff --git a/src/SEQTA.ts b/src/SEQTA.ts index 99082f4e..8aac5f43 100644 --- a/src/SEQTA.ts +++ b/src/SEQTA.ts @@ -26,6 +26,7 @@ import { injectYouTubeVideo } from './seqta/ui/VideoLoader' import { initializeSettingsState, settingsState } from './seqta/utils/listeners/SettingsState' import { StorageChangeHandler } from './seqta/utils/listeners/StorageChanges' import { AddBetterSEQTAElements } from './seqta/ui/AddBetterSEQTAElements' +import { eventManager } from './seqta/utils/listeners/EventManager' declare global { interface Window { @@ -376,7 +377,7 @@ export function RemoveBackground() { bk3[0].remove() } -export function waitForElm(selector: string) { +export async function waitForElm(selector: string) { return new Promise((resolve) => { const querySelector = () => document.querySelector(selector); @@ -434,65 +435,47 @@ function removeThemeTagsFromNotices () { } async function updateIframesWithDarkMode(): Promise { - // Load the CSS file to overwrite iFrame default CSS - const cssLink = document.createElement('style') - cssLink.classList.add('iframecss') - const cssContent = document.createTextNode(iframeCSS) - cssLink.appendChild(cssContent) + const cssLink = document.createElement('style'); + cssLink.classList.add('iframecss'); + const cssContent = document.createTextNode(iframeCSS); + cssLink.appendChild(cssContent); - const observer = new MutationObserver(async (mutationsList) => { - for (const mutation of mutationsList) { - for (const node of mutation.addedNodes) { - if (node.nodeName === 'IFRAME') { - const iframe = node as HTMLIFrameElement - try { - applyDarkModeToIframe(iframe, cssLink, settingsState.DarkMode); + eventManager.register('iframeAdded', { + elementType: 'iframe', + customCheck: (element: Element) => !element.classList.contains('iframecss'), + }, (element) => { + const iframe = element as HTMLIFrameElement; + try { + applyDarkModeToIframe(iframe, cssLink, settingsState.DarkMode); - // check if it is a text editor frame - if (node instanceof HTMLElement && node.classList.contains('cke_wysiwyg_frame')) { - await delay(100) - // enable spellcheck - iframe.contentDocument?.body.setAttribute('spellcheck', 'true') - } - } catch (error) { - console.error('Error applying dark mode:', error) - } - } + if (element.classList.contains('cke_wysiwyg_frame')) { + (async () => { + await delay(100); + iframe.contentDocument?.body.setAttribute('spellcheck', 'true'); + })(); } + } catch (error) { + console.error('Error applying dark mode:', error); } }); - - if (document.body) { - observer.observe(document.body, { - childList: true, - subtree: true, - }); - } else { - document.addEventListener('DOMContentLoaded', () => { - observer.observe(document.body, { - childList: true, - subtree: true, - }); - }); - } } function applyDarkModeToIframe(iframe: HTMLIFrameElement, cssLink: HTMLStyleElement, DarkMode: boolean): void { - const iframeDocument = iframe.contentDocument - if (!iframeDocument) return + const iframeDocument = iframe.contentDocument; + if (!iframeDocument) return; if (iframeDocument.readyState !== 'complete') { iframe.onload = () => { - applyDarkModeToIframe(iframe, cssLink, DarkMode) - } - return + applyDarkModeToIframe(iframe, cssLink, DarkMode); + }; + return; } - - if (DarkMode) iframeDocument.documentElement.classList.add('dark') - const head = iframeDocument.head + if (DarkMode) iframeDocument.documentElement.classList.add('dark'); + + const head = iframeDocument.head; if (head && !head.innerHTML.includes('iframecss')) { - head.innerHTML += cssLink.outerHTML + head.innerHTML += cssLink.outerHTML; } } @@ -509,143 +492,151 @@ function SortMessagePageItems(messagesParentElement: any) { } async function LoadPageElements(): Promise { - await AddBetterSEQTAElements() - const sublink: string | undefined = window.location.href.split('/')[4] + await AddBetterSEQTAElements(); + const sublink: string | undefined = window.location.href.split('/')[4]; - const observer = new MutationObserver(function (mutations_list) { - mutations_list.forEach(function (mutation) { - mutation.addedNodes.forEach(function (added_node) { - const node = added_node as HTMLElement - if (node.classList.contains('messages')) { - let element = document.getElementById('title')!.firstChild as HTMLElement - element.innerText = 'Direct Messages' - document.title = 'Direct Messages ― SEQTA Learn' - SortMessagePageItems(added_node) + eventManager.register('messagesAdded', { + elementType: 'div', + className: 'messages', + }, handleMessages); - waitForElm('[data-message]').then(() => { - animate( - '[data-message]', - { opacity: [0, 1], y: [10, 0] }, - { - delay: stagger(0.05), - duration: 0.5, - easing: [.22, .03, .26, 1] - } - ) - }) - } else if (node.classList.contains('notices')) { - CheckNoticeTextColour(added_node) - } else if (node.classList.contains('dashboard')) { - let ranOnce = false; - waitForElm('.dashlet').then(() => { - animate( - '.dashboard > *', - { opacity: [0, 1], y: [10, 0] }, - { - delay: stagger(0.1), - duration: 0.5, - easing: [.22, .03, .26, 1] - } - ) - if (ranOnce) return; - ranOnce = true; - }) - } else if (node.classList.contains('documents')) { - let ranOnce = false; - waitForElm('.document').then(() => { - if (ranOnce) return; - ranOnce = true; - animate( - '.documents tbody tr.document', - { opacity: [0, 1], y: [10, 0] }, - { - delay: stagger(0.05), - duration: 0.5, - easing: [.22, .03, .26, 1] - } - ) - }) - } else if (node.classList.contains('reports')) { - let ranOnce = false; - waitForElm('.report').then(() => { - if (ranOnce) return; - ranOnce = true; - animate( - '.reports .item', - { opacity: [0, 1], y: [10, 0] }, - { - delay: stagger(0.05, { start: 0.2 }), - duration: 0.5, - easing: [.22, .03, .26, 1] - } - ) - }) - } - }) - }) - }) + eventManager.register('noticesAdded', { + elementType: 'div', + className: 'notices', + }, CheckNoticeTextColour); - observer.observe(document.querySelector('#main') as HTMLElement, { - subtree: false, - childList: true, - }) + eventManager.register('dashboardAdded', { + elementType: 'div', + className: 'dashboard', + }, handleDashboard); - async function handleNewsPage(): Promise { - console.log('[BetterSEQTA+] Started Init') - if (settingsState.onoff) { - SendNewsPage() - if (settingsState.notificationcollector) { - enableNotificationCollector() - } - finishLoad() - } - } + eventManager.register('documentsAdded', { + elementType: 'div', + className: 'documents', + }, handleDocuments); - async function handleDefault(): Promise { - finishLoad() - if (settingsState.notificationcollector) { - enableNotificationCollector() - } - } + eventManager.register('reportsAdded', { + elementType: 'div', + className: 'reports', + }, handleReports); + await handleSublink(sublink); +} + +async function handleSublink(sublink: string | undefined): Promise { switch (sublink) { case 'news': - await handleNewsPage() - break + await handleNewsPage(); + break; case 'home': case undefined: - window.location.replace(`${location.origin}/#?page=/home`) - LoadInit() - break + window.location.replace(`${location.origin}/#?page=/home`); + LoadInit(); + break; default: - await handleDefault() - break + await handleDefault(); + break; } } -function CheckNoticeTextColour(notice: any) { - const observer = new MutationObserver(function (mutations_list) { - mutations_list.forEach(function (mutation) { - mutation.addedNodes.forEach(function (added_node) { - const node = added_node as HTMLElement - if (node.classList.contains('notice')) { - var hex = node.style.cssText.split(' ')[1] - if (hex) { - const hex1 = hex.slice(0,-1) - var threshold = GetThresholdOfColor(hex1) - if (settingsState.DarkMode && threshold < 100) { - node.style.cssText = '--color: undefined;' - } - } - } - }) - }) - }) +async function handleNewsPage(): Promise { + console.log('[BetterSEQTA+] Started Init'); + if (settingsState.onoff) { + SendNewsPage(); + if (settingsState.notificationcollector) { + enableNotificationCollector(); + } + finishLoad(); + } +} - observer.observe(notice, { - subtree: true, - childList: true, - }) +async function handleDefault(): Promise { + finishLoad(); + if (settingsState.notificationcollector) { + enableNotificationCollector(); + } +} + +async function handleMessages(node: Element): Promise { + if (!(node instanceof HTMLElement)) return; + + const element = document.getElementById('title')!.firstChild as HTMLElement; + element.innerText = 'Direct Messages'; + document.title = 'Direct Messages ― SEQTA Learn'; + SortMessagePageItems(node); + + await waitForElm('[data-message]'); + animate( + '[data-message]', + { opacity: [0, 1], y: [10, 0] }, + { + delay: stagger(0.05), + duration: 0.5, + easing: [.22, .03, .26, 1] + } + ); +} + +async function handleDashboard(node: Element): Promise { + if (!(node instanceof HTMLElement)) return; + + await waitForElm('.dashlet'); + animate( + '.dashboard > *', + { opacity: [0, 1], y: [10, 0] }, + { + delay: stagger(0.1), + duration: 0.5, + easing: [.22, .03, .26, 1] + } + ); +} + +async function handleDocuments(node: Element): Promise { + if (!(node instanceof HTMLElement)) return; + + await waitForElm('.document'); + animate( + '.documents tbody tr.document', + { opacity: [0, 1], y: [10, 0] }, + { + delay: stagger(0.05), + duration: 0.5, + easing: [.22, .03, .26, 1] + } + ); +} + +async function handleReports(node: Element): Promise { + if (!(node instanceof HTMLElement)) return; + + await waitForElm('.report'); + animate( + '.reports .item', + { opacity: [0, 1], y: [10, 0] }, + { + delay: stagger(0.05, { start: 0.2 }), + duration: 0.5, + easing: [.22, .03, .26, 1] + } + ); +} + +function CheckNoticeTextColour(notice: any) { + eventManager.register('noticeAdded', { + elementType: 'div', + className: 'notice', + parentElement: notice + }, (node) => { + var hex = (node as HTMLElement).style.cssText.split(' ')[1]; + if (hex) { + const hex1 = hex.slice(0,-1); + var threshold = GetThresholdOfColor(hex1); + if (settingsState.DarkMode && threshold < 100) { + (node as HTMLElement).style.cssText = '--color: undefined;'; + } + } + }); } export function tryLoad() { @@ -665,11 +656,11 @@ export function tryLoad() { if (!elm.innerText.includes('BetterSEQTA')) LoadPageElements() }) +updateIframesWithDarkMode() // Waits for page to call on load, run scripts document.addEventListener( 'load', function () { - updateIframesWithDarkMode() removeThemeTagsFromNotices() documentTextColor() }, diff --git a/src/seqta/utils/listeners/EventManager.ts b/src/seqta/utils/listeners/EventManager.ts new file mode 100644 index 00000000..5d271dc0 --- /dev/null +++ b/src/seqta/utils/listeners/EventManager.ts @@ -0,0 +1,142 @@ +interface EventListenerOptions { + elementType?: string; + textContent?: string; + className?: string; + id?: string; + customCheck?: (element: Element) => boolean; + once?: boolean; + parentElement?: Element; +} + +interface EventListener { + id: string; + options: EventListenerOptions; + callback: (element: Element) => void; + unregister: () => void; +} + +class EventManager { + private static instance: EventManager; + private listeners: Map = new Map(); + private mutationObservers: Map = new Map(); + private pendingElements: Set = new Set(); + private throttleTimeout: number = 5; // 5ms throttle + private throttleTimer: number | undefined; + private chunkSize: number = 50; // Process 50 elements per chunk + + private constructor() {} + + public static getInstance(): EventManager { + if (!EventManager.instance) { + EventManager.instance = new EventManager(); + } + return EventManager.instance; + } + + public register(event: string, options: EventListenerOptions, callback: (element: Element) => void): { unregister: () => void } { + const id = this.generateUniqueId(); + if (!this.listeners.has(event)) { + this.listeners.set(event, []); + } + const unregister = () => this.unregisterById(event, id); + this.listeners.get(event)!.push({ id, options, callback, unregister }); + this.startObserving(options.parentElement); + return { unregister }; + } + + public unregister(event: string): void { + if (this.listeners.has(event)) { + this.listeners.delete(event); + } + } + + private unregisterById(event: string, id: string): void { + if (this.listeners.has(event)) { + const listeners = this.listeners.get(event)!; + this.listeners.set(event, listeners.filter(listener => listener.id !== id)); + } + } + + private startObserving(parentElement?: Element): void { + const elementToObserve = parentElement || document.documentElement; + if (!this.mutationObservers.has(elementToObserve)) { + const observer = new MutationObserver(this.handleMutations.bind(this)); + observer.observe(elementToObserve, { + childList: true, + subtree: true, + }); + this.mutationObservers.set(elementToObserve, observer); + } + } + + private handleMutations(mutations: MutationRecord[]): void { + mutations.forEach(mutation => { + if (mutation.type === 'childList') { + mutation.addedNodes.forEach(node => { + if (node.nodeType === Node.ELEMENT_NODE) { + this.pendingElements.add(node as Element); + } + }); + } + }); + + this.throttleCheckElements(); + } + + private throttleCheckElements(): void { + if (this.throttleTimer) return; + + this.throttleTimer = window.setTimeout(() => { + this.processPendingElements(); + this.throttleTimer = undefined; + }, this.throttleTimeout); + } + + private async processPendingElements(): Promise { + const elements = Array.from(this.pendingElements); + this.pendingElements.clear(); + for (let i = 0; i < elements.length; i += this.chunkSize) { + const chunk = elements.slice(i, i + this.chunkSize); + await this.processChunk(chunk); + } + } + + private async processChunk(chunk: Element[]): Promise { + return new Promise((resolve) => { + requestAnimationFrame(async () => { + for (const element of chunk) { + await this.checkElement(element); + } + resolve(); + }); + }); + } + + private async checkElement(element: Element): Promise { + for (const [event, listeners] of this.listeners.entries()) { + for (const { id, options, callback } of listeners) { + if (this.matchesOptions(element, options)) { + await callback(element); + if (options.once) { + this.unregisterById(event, id); + } + } + } + } + } + + private matchesOptions(element: Element, options: EventListenerOptions): boolean { + if (options.elementType && element.tagName.toLowerCase() !== options.elementType.toLowerCase()) return false; + if (options.textContent && element.textContent !== options.textContent) return false; + if (options.className && !element.classList.contains(options.className)) return false; + if (options.id && element.id !== options.id) return false; + if (options.customCheck && !options.customCheck(element)) return false; + return true; + } + + private generateUniqueId(): string { + return '_' + Math.random().toString(36).substr(2, 9); + } +} + +export const eventManager = EventManager.getInstance();