mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-06-17 17:07:07 +00:00
perf: reduce startup work and fix grade analytics bar chart animation
Batch settings storage writes, tier plugin startup, lazy-load heavy UI chunks, and optimize global search indexing. Stop tweening bar height in grade analytics to prevent invalid negative SVG rect values. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+4
-3
@@ -6,7 +6,7 @@ import documentLoadCSS from "@/css/documentload.scss?inline";
|
|||||||
import icon48 from "@/resources/icons/icon-48.png?base64";
|
import icon48 from "@/resources/icons/icon-48.png?base64";
|
||||||
import browser from "webextension-polyfill";
|
import browser from "webextension-polyfill";
|
||||||
|
|
||||||
import * as plugins from "@/plugins";
|
import { init as Monofile } from "@/plugins/monofile";
|
||||||
import { main } from "@/seqta/main";
|
import { main } from "@/seqta/main";
|
||||||
import { delay } from "./seqta/utils/delay";
|
import { delay } from "./seqta/utils/delay";
|
||||||
import { initializeHideSensitiveToggle } from "@/seqta/utils/hideSensitiveToggle";
|
import { initializeHideSensitiveToggle } from "@/seqta/utils/hideSensitiveToggle";
|
||||||
@@ -104,10 +104,11 @@ async function init() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await main();
|
await main();
|
||||||
plugins.Monofile();
|
Monofile();
|
||||||
|
|
||||||
if (settingsState.onoff) {
|
if (settingsState.onoff) {
|
||||||
await plugins.initializePlugins();
|
const { initializePlugins } = await import("@/plugins/runtime");
|
||||||
|
await initializePlugins();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (settingsState.devMode) {
|
if (settingsState.devMode) {
|
||||||
|
|||||||
+5
-4
@@ -10,6 +10,7 @@ import {
|
|||||||
performCloudSettingsUploadWithRetry,
|
performCloudSettingsUploadWithRetry,
|
||||||
requestCloudSettingsDebouncedUpload,
|
requestCloudSettingsDebouncedUpload,
|
||||||
runCloudSettingsPoll,
|
runCloudSettingsPoll,
|
||||||
|
withSuppressedCloudAutoUpload,
|
||||||
} from "./background/cloudSettingsAutoSync";
|
} from "./background/cloudSettingsAutoSync";
|
||||||
import { isAllowedFetchUrl } from "@/seqta/utils/allowedFetchUrl";
|
import { isAllowedFetchUrl } from "@/seqta/utils/allowedFetchUrl";
|
||||||
|
|
||||||
@@ -573,10 +574,10 @@ function getDefaultValues(): SettingsState {
|
|||||||
return getDefaultSettingsState();
|
return getDefaultSettingsState();
|
||||||
}
|
}
|
||||||
|
|
||||||
function SetStorageValue(object: any) {
|
function SetStorageValue(object: SettingsState) {
|
||||||
for (var i in object) {
|
void withSuppressedCloudAutoUpload(() =>
|
||||||
browser.storage.local.set({ [i]: object[i] });
|
browser.storage.local.set(object as Record<string, unknown>),
|
||||||
}
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One-time migration for 3.6.5: opt upgraders into Global Search + indexing + transparency defaults. */
|
/** One-time migration for 3.6.5: opt upgraders into Global Search + indexing + transparency defaults. */
|
||||||
|
|||||||
@@ -457,6 +457,17 @@ function onStorageChanged(
|
|||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function withSuppressedCloudAutoUpload<T>(
|
||||||
|
operation: () => T | Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
suppressAutoUploadDuringRestore = true;
|
||||||
|
try {
|
||||||
|
return await operation();
|
||||||
|
} finally {
|
||||||
|
suppressAutoUploadDuringRestore = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function initCloudSettingsAutoSync(deps: { reloadSeqtaPages: () => void }): void {
|
export function initCloudSettingsAutoSync(deps: { reloadSeqtaPages: () => void }): void {
|
||||||
reloadSeqtaPagesFn = deps.reloadSeqtaPages;
|
reloadSeqtaPagesFn = deps.reloadSeqtaPages;
|
||||||
if (autoSyncInitialized) return;
|
if (autoSyncInitialized) return;
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte'
|
import { onMount } from 'svelte'
|
||||||
import ColourPicker from './ColourPicker.tsx';
|
import type { Component } from 'svelte'
|
||||||
import ReactAdapter from './utils/ReactAdapter.svelte';
|
|
||||||
import { animate } from 'motion';
|
import { animate } from 'motion';
|
||||||
import { delay } from '@/seqta/utils/delay.ts'
|
import { delay } from '@/seqta/utils/delay.ts'
|
||||||
|
|
||||||
@@ -15,6 +14,19 @@
|
|||||||
|
|
||||||
let background = $state<HTMLDivElement | null>(null);
|
let background = $state<HTMLDivElement | null>(null);
|
||||||
let content = $state<HTMLDivElement | null>(null);
|
let content = $state<HTMLDivElement | null>(null);
|
||||||
|
let ReactAdapter = $state<Component | null>(null);
|
||||||
|
let ColourPickerEl = $state<unknown>(null);
|
||||||
|
let pickerReady = $state(false);
|
||||||
|
|
||||||
|
const loadPicker = async () => {
|
||||||
|
const [adapterMod, pickerMod] = await Promise.all([
|
||||||
|
import('./utils/ReactAdapter.svelte'),
|
||||||
|
import('./ColourPicker.tsx'),
|
||||||
|
]);
|
||||||
|
ReactAdapter = adapterMod.default;
|
||||||
|
ColourPickerEl = pickerMod.default;
|
||||||
|
pickerReady = true;
|
||||||
|
};
|
||||||
|
|
||||||
const closePicker = async () => {
|
const closePicker = async () => {
|
||||||
if (standalone) return;
|
if (standalone) return;
|
||||||
@@ -37,10 +49,11 @@
|
|||||||
);
|
);
|
||||||
|
|
||||||
await delay(400);
|
await delay(400);
|
||||||
hidePicker();
|
hidePicker?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
void loadPicker().then(() => {
|
||||||
if (standalone) return;
|
if (standalone) return;
|
||||||
if (!background || !content) return;
|
if (!background || !content) return;
|
||||||
|
|
||||||
@@ -59,6 +72,7 @@
|
|||||||
damping: 30
|
damping: 30
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
|
||||||
const handleEscapeKey = (e: KeyboardEvent) => {
|
const handleEscapeKey = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
@@ -82,7 +96,9 @@
|
|||||||
|
|
||||||
{#if standalone}
|
{#if standalone}
|
||||||
<div class="h-auto overflow-clip rounded-xl">
|
<div class="h-auto overflow-clip rounded-xl">
|
||||||
<ReactAdapter customOnChange={customOnChange} customState={customState} savePresets={savePresets} el={ColourPicker} />
|
{#if pickerReady && ReactAdapter && ColourPickerEl}
|
||||||
|
<ReactAdapter customOnChange={customOnChange} customState={customState} savePresets={savePresets} el={ColourPickerEl} />
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
@@ -96,7 +112,9 @@
|
|||||||
bind:this={content}
|
bind:this={content}
|
||||||
class="p-4 h-auto bg-white rounded-xl border shadow-lg cursor-auto dark:bg-zinc-800 border-zinc-100 dark:border-zinc-700"
|
class="p-4 h-auto bg-white rounded-xl border shadow-lg cursor-auto dark:bg-zinc-800 border-zinc-100 dark:border-zinc-700"
|
||||||
>
|
>
|
||||||
<ReactAdapter customOnChange={customOnChange} customState={customState} savePresets={savePresets} el={ColourPicker} />
|
{#if pickerReady && ReactAdapter && ColourPickerEl}
|
||||||
|
<ReactAdapter customOnChange={customOnChange} customState={customState} savePresets={savePresets} el={ColourPickerEl} />
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -59,23 +59,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="overflow-hidden px-4 h-full">
|
<div class="overflow-hidden px-4 h-full">
|
||||||
<MotionDiv
|
{#each tabs as { Content, props }, index (index)}
|
||||||
class="h-full"
|
{#if activeTab === index}
|
||||||
animate={{ x: `${-activeTab * 100}%` }}
|
|
||||||
transition={springTransition}
|
|
||||||
>
|
|
||||||
<div class="flex">
|
|
||||||
{#each tabs as { Content, props }, index}
|
|
||||||
<div
|
<div
|
||||||
role="tabpanel"
|
role="tabpanel"
|
||||||
aria-hidden={activeTab !== index}
|
class="focus:outline-none w-full pt-2 overflow-y-scroll no-scrollbar pb-2 h-full tab active"
|
||||||
class="absolute focus:outline-none w-full pt-2 transition-opacity duration-300 overflow-y-scroll no-scrollbar pb-2 h-full tab {activeTab === index ? 'opacity-100 active' : 'opacity-0'}"
|
>
|
||||||
style="left: {index * 100}%;">
|
<div class="fixed top-0 w-full h-8 bg-gradient-to-b to-transparent pointer-events-none z-[100] from-white dark:from-zinc-800 dark:to-transparent"></div>
|
||||||
<div style="left: {index * 100}%;" class="fixed top-0 w-full h-8 bg-gradient-to-b to-transparent pointer-events-none z-[100] from-white dark:from-zinc-800 dark:to-transparent"></div>
|
|
||||||
<Content {...props} />
|
<Content {...props} />
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</MotionDiv>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import logo from '@/resources/icons/betterseqta-dark-full.png';
|
import logo from '@/resources/icons/betterseqta-dark-full.png';
|
||||||
import logoDark from '@/resources/icons/betterseqta-light-full.png';
|
import logoDark from '@/resources/icons/betterseqta-light-full.png';
|
||||||
import { closeStore } from '@/seqta/ui/renderStore'
|
|
||||||
import browser from 'webextension-polyfill';
|
import browser from 'webextension-polyfill';
|
||||||
import CloudHeader from './CloudHeader.svelte';
|
import CloudHeader from './CloudHeader.svelte';
|
||||||
|
|
||||||
|
const handleCloseStore = () => {
|
||||||
|
void import('@/seqta/ui/renderStore').then((module) => module.closeStore());
|
||||||
|
};
|
||||||
|
|
||||||
// Props
|
// Props
|
||||||
let { searchTerm, setSearchTerm, darkMode, activeTab, setActiveTab } = $props<{
|
let { searchTerm, setSearchTerm, darkMode, activeTab, setActiveTab } = $props<{
|
||||||
searchTerm: string,
|
searchTerm: string,
|
||||||
@@ -64,7 +67,7 @@
|
|||||||
|
|
||||||
<!-- Close Button -->
|
<!-- Close Button -->
|
||||||
<button
|
<button
|
||||||
onclick={closeStore}
|
onclick={handleCloseStore}
|
||||||
class="p-1 px-3"
|
class="p-1 px-3"
|
||||||
>
|
>
|
||||||
<span class="text-2xl font-IconFamily"></span>
|
<span class="text-2xl font-IconFamily"></span>
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
import type { CustomTheme, ThemeList } from '@/types/CustomThemes'
|
import type { CustomTheme, ThemeList } from '@/types/CustomThemes'
|
||||||
import { onDestroy, onMount } from 'svelte'
|
import { onDestroy, onMount } from 'svelte'
|
||||||
import browser from 'webextension-polyfill'
|
import browser from 'webextension-polyfill'
|
||||||
import { OpenThemeCreator } from '@/plugins/built-in/themes/ThemeCreator'
|
|
||||||
import { OpenStorePage } from '@/seqta/ui/renderStore'
|
|
||||||
import { themeUpdates } from '@/interface/hooks/ThemeUpdates'
|
import { themeUpdates } from '@/interface/hooks/ThemeUpdates'
|
||||||
import { closeExtensionPopup } from '@/seqta/utils/Closers/closeExtensionPopup'
|
import { closeExtensionPopup } from '@/seqta/utils/Closers/closeExtensionPopup'
|
||||||
import { ThemeManager } from '@/plugins/built-in/themes/theme-manager'
|
import { ThemeManager } from '@/plugins/built-in/themes/theme-manager'
|
||||||
@@ -127,6 +125,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openStorePage = async () => {
|
||||||
|
const { OpenStorePage } = await import('@/seqta/ui/renderStore')
|
||||||
|
OpenStorePage()
|
||||||
|
}
|
||||||
|
|
||||||
|
const openThemeCreator = async (themeId?: string) => {
|
||||||
|
const { OpenThemeCreator } = await import('@/plugins/built-in/themes/ThemeCreator')
|
||||||
|
OpenThemeCreator(themeId)
|
||||||
|
closeExtensionPopup()
|
||||||
|
}
|
||||||
|
|
||||||
const handleToggleFavorite = async (theme: CustomTheme, e: MouseEvent) => {
|
const handleToggleFavorite = async (theme: CustomTheme, e: MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (!cloudLoggedIn) {
|
if (!cloudLoggedIn) {
|
||||||
@@ -212,8 +221,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="absolute z-20 flex w-8 h-8 p-2 text-white transition-all rounded-full delay-[20ms] opacity-0 top-1/4 right-2 bg-black/50 place-items-center group-hover:opacity-100 group-hover:top-1/2 -translate-y-1/2"
|
class="absolute z-20 flex w-8 h-8 p-2 text-white transition-all rounded-full delay-[20ms] opacity-0 top-1/4 right-2 bg-black/50 place-items-center group-hover:opacity-100 group-hover:top-1/2 -translate-y-1/2"
|
||||||
onclick={(event) => { event.stopPropagation(); OpenThemeCreator(theme.id); closeExtensionPopup() }}
|
onclick={(event) => { event.stopPropagation(); void openThemeCreator(theme.id) }}
|
||||||
onkeydown={(event) => { if (event.key === 'Enter' || event.key === ' ') OpenThemeCreator(theme.id); closeExtensionPopup() }}
|
onkeydown={(event) => { if (event.key === 'Enter' || event.key === ' ') void openThemeCreator(theme.id) }}
|
||||||
role="button"
|
role="button"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
>
|
>
|
||||||
@@ -261,7 +270,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onclick={() => OpenStorePage()}
|
onclick={() => void openStorePage()}
|
||||||
class="flex justify-center items-center w-full rounded-xl transition aspect-theme bg-zinc-100 dark:bg-zinc-900 dark:text-white"
|
class="flex justify-center items-center w-full rounded-xl transition aspect-theme bg-zinc-100 dark:bg-zinc-900 dark:text-white"
|
||||||
>
|
>
|
||||||
<span class="text-xl font-IconFamily"></span>
|
<span class="text-xl font-IconFamily"></span>
|
||||||
@@ -269,7 +278,7 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onclick={() => { OpenThemeCreator(); closeExtensionPopup() }}
|
onclick={() => void openThemeCreator()}
|
||||||
class="flex justify-center items-center w-full rounded-xl transition aspect-theme bg-zinc-100 dark:bg-zinc-900 dark:text-white"
|
class="flex justify-center items-center w-full rounded-xl transition aspect-theme bg-zinc-100 dark:bg-zinc-900 dark:text-white"
|
||||||
>
|
>
|
||||||
<span class="text-xl font-IconFamily"></span>
|
<span class="text-xl font-IconFamily"></span>
|
||||||
|
|||||||
@@ -1,53 +1 @@
|
|||||||
type SettingsPopupCallback = () => void;
|
export { settingsPopup } from "@/seqta/utils/settingsPopup";
|
||||||
|
|
||||||
/**
|
|
||||||
* This is a singleton that triggers an update when the settings popup is closed.
|
|
||||||
* This is used to close the colour picker.
|
|
||||||
* Usage:
|
|
||||||
* settingsPopup.addListener(() => {
|
|
||||||
* console.log('Settings popup closed');
|
|
||||||
* });
|
|
||||||
*/
|
|
||||||
class SettingsPopup {
|
|
||||||
private static instance: SettingsPopup;
|
|
||||||
private listeners: Set<SettingsPopupCallback> = new Set();
|
|
||||||
|
|
||||||
private constructor() {}
|
|
||||||
|
|
||||||
public static getInstance(): SettingsPopup {
|
|
||||||
if (!SettingsPopup.instance) {
|
|
||||||
SettingsPopup.instance = new SettingsPopup();
|
|
||||||
}
|
|
||||||
return SettingsPopup.instance;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Registers a callback function to be invoked when the settings popup is closed.
|
|
||||||
*
|
|
||||||
* @param {SettingsPopupCallback} callback The function to call when the settings popup closes.
|
|
||||||
* This callback takes no arguments and returns void.
|
|
||||||
*/
|
|
||||||
public addListener(callback: SettingsPopupCallback): void {
|
|
||||||
this.listeners.add(callback);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unregisters a previously added callback function.
|
|
||||||
* After calling this method, the provided callback will no longer be invoked when the settings popup closes.
|
|
||||||
*
|
|
||||||
* @param {SettingsPopupCallback} callback The callback function to remove from the listeners.
|
|
||||||
*/
|
|
||||||
public removeListener(callback: SettingsPopupCallback): void {
|
|
||||||
this.listeners.delete(callback);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Invokes all registered listener callbacks.
|
|
||||||
* This method should be called when the settings popup is closed to notify all subscribed components or services.
|
|
||||||
*/
|
|
||||||
public triggerClose(): void {
|
|
||||||
this.listeners.forEach((callback) => callback());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const settingsPopup = SettingsPopup.getInstance();
|
|
||||||
|
|||||||
@@ -14,11 +14,11 @@
|
|||||||
import { OpenWhatsNewPopup } from "@/seqta/utils/Openers/OpenWhatsNewPopup";
|
import { OpenWhatsNewPopup } from "@/seqta/utils/Openers/OpenWhatsNewPopup";
|
||||||
//import { OpenMinecraftServerPopup } from "@/seqta/utils/Openers/OpenMinecraftServerPopup";
|
//import { OpenMinecraftServerPopup } from "@/seqta/utils/Openers/OpenMinecraftServerPopup";
|
||||||
|
|
||||||
import ColourPicker from "../components/ColourPicker.svelte";
|
import type { Component } from "svelte";
|
||||||
import FontPickerModal from "../components/FontPickerModal.svelte";
|
import FontPickerModal from "../components/FontPickerModal.svelte";
|
||||||
import CloudPanel from "../components/CloudPanel.svelte";
|
import CloudPanel from "../components/CloudPanel.svelte";
|
||||||
import DisclaimerModal from "../components/DisclaimerModal.svelte";
|
import DisclaimerModal from "../components/DisclaimerModal.svelte";
|
||||||
import { settingsPopup } from "../hooks/SettingsPopup";
|
import { settingsPopup } from "@/seqta/utils/settingsPopup";
|
||||||
import {
|
import {
|
||||||
checkGithubReleaseUpdate,
|
checkGithubReleaseUpdate,
|
||||||
dismissNightlyUpdate,
|
dismissNightlyUpdate,
|
||||||
@@ -64,7 +64,12 @@
|
|||||||
}, 10000);
|
}, 10000);
|
||||||
};
|
};
|
||||||
|
|
||||||
const openColourPicker = () => {
|
let ColourPickerComponent = $state<Component | null>(null);
|
||||||
|
|
||||||
|
const openColourPicker = async () => {
|
||||||
|
if (!ColourPickerComponent) {
|
||||||
|
ColourPickerComponent = (await import("../components/ColourPicker.svelte")).default;
|
||||||
|
}
|
||||||
showColourPicker = true;
|
showColourPicker = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -363,8 +368,8 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if showColourPicker}
|
{#if showColourPicker && ColourPickerComponent}
|
||||||
<ColourPicker
|
<ColourPickerComponent
|
||||||
hidePicker={() => {
|
hidePicker={() => {
|
||||||
showColourPicker = false;
|
showColourPicker = false;
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -131,16 +131,16 @@ const assessmentsOverviewPlugin: Plugin<{}> = {
|
|||||||
|
|
||||||
if (requestId !== loadRequestId) return;
|
if (requestId !== loadRequestId) return;
|
||||||
|
|
||||||
renderSkeletonLoader(container);
|
void renderSkeletonLoader(container);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await getAssessmentsData();
|
const data = await getAssessmentsData();
|
||||||
if (requestId !== loadRequestId) return;
|
if (requestId !== loadRequestId) return;
|
||||||
renderGrid(container, data);
|
void renderGrid(container, data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (requestId !== loadRequestId) return;
|
if (requestId !== loadRequestId) return;
|
||||||
console.error("Failed to load assessments:", err);
|
console.error("Failed to load assessments:", err);
|
||||||
renderErrorState(
|
void renderErrorState(
|
||||||
container,
|
container,
|
||||||
err instanceof Error ? err.message : "Unknown error",
|
err instanceof Error ? err.message : "Unknown error",
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,4 @@
|
|||||||
import renderSvelte from "@/interface/main";
|
|
||||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||||
import AssessmentsOverview from "./AssessmentsOverview.svelte";
|
|
||||||
import SkeletonLoader from "./SkeletonLoader.svelte";
|
|
||||||
import ErrorState from "./ErrorState.svelte";
|
|
||||||
import { unmount } from "svelte";
|
import { unmount } from "svelte";
|
||||||
|
|
||||||
let currentApp: any = null;
|
let currentApp: any = null;
|
||||||
@@ -119,26 +115,42 @@ function prepareContainer(container: HTMLElement) {
|
|||||||
watchOverviewTheme(container);
|
watchOverviewTheme(container);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderGrid(container: HTMLElement, data: any) {
|
async function mountOverviewComponent(
|
||||||
if (currentApp) unmount(currentApp);
|
container: HTMLElement,
|
||||||
prepareContainer(container);
|
loader: () => Promise<{ default: any }>,
|
||||||
currentApp = renderSvelte(AssessmentsOverview, container, { data });
|
props: Record<string, unknown> = {},
|
||||||
|
) {
|
||||||
|
const [{ default: renderSvelte }, { default: Component }] = await Promise.all([
|
||||||
|
import("@/interface/main"),
|
||||||
|
loader(),
|
||||||
|
]);
|
||||||
|
currentApp = renderSvelte(Component, container, props);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderSkeletonLoader(container: HTMLElement) {
|
export async function renderGrid(container: HTMLElement, data: any) {
|
||||||
if (currentApp) unmount(currentApp);
|
if (currentApp) unmount(currentApp);
|
||||||
prepareContainer(container);
|
prepareContainer(container);
|
||||||
currentApp = renderSvelte(SkeletonLoader, container);
|
await mountOverviewComponent(container, () => import("./AssessmentsOverview.svelte"), {
|
||||||
|
data,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderLoadingState(container: HTMLElement) {
|
export async function renderSkeletonLoader(container: HTMLElement) {
|
||||||
renderSkeletonLoader(container);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function renderErrorState(container: HTMLElement, error: string) {
|
|
||||||
if (currentApp) unmount(currentApp);
|
if (currentApp) unmount(currentApp);
|
||||||
prepareContainer(container);
|
prepareContainer(container);
|
||||||
currentApp = renderSvelte(ErrorState, container, { error });
|
await mountOverviewComponent(container, () => import("./SkeletonLoader.svelte"));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renderLoadingState(container: HTMLElement) {
|
||||||
|
await renderSkeletonLoader(container);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renderErrorState(container: HTMLElement, error: string) {
|
||||||
|
if (currentApp) unmount(currentApp);
|
||||||
|
prepareContainer(container);
|
||||||
|
await mountOverviewComponent(container, () => import("./ErrorState.svelte"), {
|
||||||
|
error,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function teardownOverviewUi() {
|
export function teardownOverviewUi() {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
import { circOut, quintOut } from 'svelte/easing';
|
import { circOut, quintOut } from 'svelte/easing';
|
||||||
import { type StaticCommandItem } from '../core/commands';
|
import { type StaticCommandItem } from '../core/commands';
|
||||||
import type { CombinedResult } from '../core/types';
|
import type { CombinedResult } from '../core/types';
|
||||||
import { createSearchIndexes, performSearch as doSearch } from '../search/searchUtils';
|
import { createSearchIndexes, applyDynamicIndexDelta, performSearch as doSearch, type DynamicItemsUpdatedDetail } from '../search/searchUtils';
|
||||||
import Fuse from 'fuse.js';
|
import Fuse from 'fuse.js';
|
||||||
import Calculator from './Calculator.svelte';
|
import Calculator from './Calculator.svelte';
|
||||||
import { actionMap } from '../indexing/actions';
|
import { actionMap } from '../indexing/actions';
|
||||||
@@ -129,7 +129,31 @@
|
|||||||
|
|
||||||
window.addEventListener('indexing-progress', progressHandler as EventListener);
|
window.addEventListener('indexing-progress', progressHandler as EventListener);
|
||||||
|
|
||||||
const itemsUpdatedHandler = () => {
|
const itemsUpdatedHandler = (event: Event) => {
|
||||||
|
const detail = (event as CustomEvent<DynamicItemsUpdatedDetail>).detail;
|
||||||
|
|
||||||
|
if (
|
||||||
|
detail?.vectorUpdate &&
|
||||||
|
!detail.changedItems?.length &&
|
||||||
|
!detail.removedIds?.length
|
||||||
|
) {
|
||||||
|
performSearch();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (detail?.incremental && !detail.fullRebuild) {
|
||||||
|
const updatedFuse = applyDynamicIndexDelta(
|
||||||
|
dynamicContentFuse,
|
||||||
|
dynamicIdToItemMap,
|
||||||
|
detail,
|
||||||
|
);
|
||||||
|
if (updatedFuse) {
|
||||||
|
dynamicContentFuse = updatedFuse;
|
||||||
|
performSearch();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setupSearchIndexes();
|
setupSearchIndexes();
|
||||||
performSearch();
|
performSearch();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -286,10 +286,10 @@ const globalSearchPlugin: Plugin<typeof settings> = {
|
|||||||
const title = document.querySelector("#title");
|
const title = document.querySelector("#title");
|
||||||
|
|
||||||
if (title) {
|
if (title) {
|
||||||
mountSearchBar(title, api, appRef);
|
void mountSearchBar(title, api, appRef);
|
||||||
} else {
|
} else {
|
||||||
const titleElement = await waitForElm("#title", true, 100, 60);
|
const titleElement = await waitForElm("#title", true, 100, 60);
|
||||||
mountSearchBar(titleElement, api, appRef);
|
void mountSearchBar(titleElement, api, appRef);
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import renderSvelte from "@/interface/main";
|
|
||||||
import SearchBar from "../components/SearchBar.svelte";
|
import SearchBar from "../components/SearchBar.svelte";
|
||||||
import { unmount } from "svelte";
|
import { unmount } from "svelte";
|
||||||
import { VectorWorkerManager } from "../indexing/worker/vectorWorkerManager";
|
import { VectorWorkerManager } from "../indexing/worker/vectorWorkerManager";
|
||||||
import { formatHotkeyForDisplay, isValidHotkey } from "../utils/hotkeyUtils";
|
import { formatHotkeyForDisplay, isValidHotkey } from "../utils/hotkeyUtils";
|
||||||
import browser from "webextension-polyfill";
|
import browser from "webextension-polyfill";
|
||||||
|
|
||||||
export function mountSearchBar(
|
export async function mountSearchBar(
|
||||||
titleElement: Element,
|
titleElement: Element,
|
||||||
api: any,
|
api: any,
|
||||||
appRef: {
|
appRef: {
|
||||||
@@ -305,6 +304,7 @@ export function mountSearchBar(
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const { default: renderSvelte } = await import("@/interface/main");
|
||||||
appRef.current = renderSvelte(SearchBar, searchRootShadow, {
|
appRef.current = renderSvelte(SearchBar, searchRootShadow, {
|
||||||
transparencyEffects: api.settings.transparencyEffects ? true : false,
|
transparencyEffects: api.settings.transparencyEffects ? true : false,
|
||||||
showRecentFirst: api.settings.showRecentFirst,
|
showRecentFirst: api.settings.showRecentFirst,
|
||||||
|
|||||||
@@ -184,6 +184,56 @@ export async function put(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply puts and deletes in a single readwrite transaction.
|
||||||
|
*/
|
||||||
|
export async function applyStoreDiff(
|
||||||
|
store: string,
|
||||||
|
puts: Array<{ key: string; value: any }>,
|
||||||
|
removeKeys: string[],
|
||||||
|
): Promise<void> {
|
||||||
|
if (puts.length === 0 && removeKeys.length === 0) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const db = await openDB();
|
||||||
|
|
||||||
|
if (!db.objectStoreNames.contains(store)) {
|
||||||
|
await upgradeDB(store);
|
||||||
|
const upgradedDb = await openDB();
|
||||||
|
await runStoreDiffTransaction(upgradedDb, store, puts, removeKeys);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await runStoreDiffTransaction(db, store, puts, removeKeys);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error in applyStoreDiff for store ${store}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function runStoreDiffTransaction(
|
||||||
|
db: IDBDatabase,
|
||||||
|
store: string,
|
||||||
|
puts: Array<{ key: string; value: any }>,
|
||||||
|
removeKeys: string[],
|
||||||
|
): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const tx = db.transaction(store, "readwrite");
|
||||||
|
const objectStore = tx.objectStore(store);
|
||||||
|
|
||||||
|
for (const key of removeKeys) {
|
||||||
|
objectStore.delete(key);
|
||||||
|
}
|
||||||
|
for (const { key, value } of puts) {
|
||||||
|
objectStore.put(value, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.oncomplete = () => resolve();
|
||||||
|
tx.onerror = () => reject(tx.error);
|
||||||
|
tx.onabort = () => reject(tx.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function remove(store: string, key: string): Promise<void> {
|
export async function remove(store: string, key: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const s = await getStore(store, "readwrite");
|
const s = await getStore(store, "readwrite");
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { clear, get, getAll, put, remove, resetDatabase } from "./db";
|
import { applyStoreDiff, get, getAll, put, remove, resetDatabase } from "./db";
|
||||||
import { jobs } from "./jobs";
|
import { jobs } from "./jobs";
|
||||||
import { renderComponentMap } from "./renderComponents";
|
import { decorateIndexItems } from "./renderComponents";
|
||||||
import type { IndexItem, Job, JobContext } from "./types";
|
import type { IndexItem, Job, JobContext } from "./types";
|
||||||
import { VectorWorkerManager } from "./worker/vectorWorkerManager";
|
import { VectorWorkerManager } from "./worker/vectorWorkerManager";
|
||||||
import { loadDynamicItems } from "../utils/dynamicItems";
|
import { loadDynamicItems } from "../utils/dynamicItems";
|
||||||
@@ -89,12 +89,64 @@ function shouldRun(job: Job, lastRun?: number): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getLastRunMeta(jobId: string): Promise<number | undefined> {
|
function getLastRunMeta(jobId: string): Promise<number | undefined> {
|
||||||
return getAll(META_STORE).then((metaItems) => {
|
return get(META_STORE, jobId).then((rec) => rec?.lastRun);
|
||||||
const match = metaItems.find((m: any) => m.jobId === jobId);
|
}
|
||||||
return match?.lastRun;
|
|
||||||
|
function indexItemStorageKey(item: IndexItem): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
id: item.id,
|
||||||
|
text: item.text,
|
||||||
|
category: item.category,
|
||||||
|
content: item.content,
|
||||||
|
dateAdded: item.dateAdded,
|
||||||
|
metadata: item.metadata,
|
||||||
|
actionId: item.actionId,
|
||||||
|
renderComponentId: item.renderComponentId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function indexItemsEqual(a: IndexItem, b: IndexItem): boolean {
|
||||||
|
return indexItemStorageKey(a) === indexItemStorageKey(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function diffAndStoreItems(
|
||||||
|
targetStore: string,
|
||||||
|
items: IndexItem[],
|
||||||
|
): Promise<void> {
|
||||||
|
const validItems = items.filter((i) => i && i.id);
|
||||||
|
if (validItems.length !== items.length) {
|
||||||
|
console.warn(
|
||||||
|
`[Indexer] Filtered out ${items.length - validItems.length} invalid items before storing in '${targetStore}'.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = (await getAll(targetStore)) as IndexItem[];
|
||||||
|
const existingMap = new Map(
|
||||||
|
existing.filter((i) => i?.id).map((i) => [i.id, i]),
|
||||||
|
);
|
||||||
|
const newMap = new Map(validItems.map((i) => [i.id, i]));
|
||||||
|
|
||||||
|
const puts: Array<{ key: string; value: IndexItem }> = [];
|
||||||
|
const removeKeys: string[] = [];
|
||||||
|
|
||||||
|
for (const [id, item] of newMap) {
|
||||||
|
const prev = existingMap.get(id);
|
||||||
|
if (!prev || !indexItemsEqual(prev, item)) {
|
||||||
|
puts.push({ key: id, value: item });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const id of existingMap.keys()) {
|
||||||
|
if (!newMap.has(id)) {
|
||||||
|
removeKeys.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (puts.length > 0 || removeKeys.length > 0) {
|
||||||
|
await applyStoreDiff(targetStore, puts, removeKeys);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function updateLastRunMeta(jobId: string): Promise<void> {
|
async function updateLastRunMeta(jobId: string): Promise<void> {
|
||||||
await put(META_STORE, { jobId, lastRun: Date.now() }, jobId);
|
await put(META_STORE, { jobId, lastRun: Date.now() }, jobId);
|
||||||
}
|
}
|
||||||
@@ -255,14 +307,7 @@ export async function runIndexing(): Promise<void> {
|
|||||||
await getAll(storeId ?? jobId);
|
await getAll(storeId ?? jobId);
|
||||||
const setStoredItems = async (items: IndexItem[], storeId?: string) => {
|
const setStoredItems = async (items: IndexItem[], storeId?: string) => {
|
||||||
const targetStore = storeId ?? jobId;
|
const targetStore = storeId ?? jobId;
|
||||||
await clear(targetStore);
|
await diffAndStoreItems(targetStore, items);
|
||||||
const validItems = items.filter((i) => i && i.id);
|
|
||||||
if (validItems.length !== items.length) {
|
|
||||||
console.warn(
|
|
||||||
`[Indexer Job ${jobId} -> Store ${targetStore}] Filtered out ${items.length - validItems.length} invalid items before storing.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
await Promise.all(validItems.map((i) => put(targetStore, i, i.id)));
|
|
||||||
};
|
};
|
||||||
const addItem = async (item: IndexItem, storeId?: string) => {
|
const addItem = async (item: IndexItem, storeId?: string) => {
|
||||||
const targetStore = storeId ?? jobId;
|
const targetStore = storeId ?? jobId;
|
||||||
@@ -447,35 +492,13 @@ export async function runIndexing(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
allItemsInPrimaryStores = await loadAllStoredItems();
|
allItemsInPrimaryStores = await loadAllStoredItems();
|
||||||
// Create new objects to avoid XrayWrapper issues in Firefox
|
const itemsWithComponents = decorateIndexItems(allItemsInPrimaryStores);
|
||||||
const itemsWithComponents = allItemsInPrimaryStores.map(item => {
|
|
||||||
try {
|
|
||||||
const jobDef = jobs[item.category] || Object.values(jobs).find(j => j.id === item.category) || jobs[item.renderComponentId];
|
|
||||||
let renderComponent = item.renderComponent;
|
|
||||||
if (jobDef) {
|
|
||||||
renderComponent = renderComponentMap[jobDef.renderComponentId] || renderComponent;
|
|
||||||
} else if (renderComponentMap[item.renderComponentId]) {
|
|
||||||
renderComponent = renderComponentMap[item.renderComponentId];
|
|
||||||
}
|
|
||||||
// Deep clone to avoid Firefox XrayWrapper issues with nested objects like metadata
|
|
||||||
// Use JSON serialization to ensure all nested properties are accessible
|
|
||||||
try {
|
|
||||||
const cloned = JSON.parse(JSON.stringify(item));
|
|
||||||
cloned.renderComponent = renderComponent;
|
|
||||||
return cloned;
|
|
||||||
} catch (e) {
|
|
||||||
// Fallback to shallow copy if deep clone fails
|
|
||||||
console.warn("[Indexer] Failed to deep clone item, using shallow copy:", e);
|
|
||||||
return { ...item, renderComponent };
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// Fallback: return item as-is if modification fails (Firefox XrayWrapper)
|
|
||||||
console.warn("[Indexer] Failed to add render component to item (Firefox XrayWrapper):", error);
|
|
||||||
return item;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
loadDynamicItems(itemsWithComponents);
|
loadDynamicItems(itemsWithComponents);
|
||||||
window.dispatchEvent(new Event("dynamic-items-updated"));
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("dynamic-items-updated", {
|
||||||
|
detail: { fullRebuild: true },
|
||||||
|
}),
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
stopHeartbeat();
|
stopHeartbeat();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { IndexItem } from "./types";
|
import type { IndexItem } from "./types";
|
||||||
import { put, getAll } from "./db";
|
import { getAll, put } from "./db";
|
||||||
import {
|
import {
|
||||||
buildIndexItem,
|
buildIndexItem,
|
||||||
extractTextFromValue,
|
extractTextFromValue,
|
||||||
@@ -7,10 +7,8 @@ import {
|
|||||||
pickTitle,
|
pickTitle,
|
||||||
} from "./extract";
|
} from "./extract";
|
||||||
import { isSensitiveSeqtaPath, normalizeSeqtaPath } from "./api";
|
import { isSensitiveSeqtaPath, normalizeSeqtaPath } from "./api";
|
||||||
import { loadAllStoredItems } from "./indexer";
|
import { mergeDynamicItems } from "../utils/dynamicItems";
|
||||||
import { loadDynamicItems } from "../utils/dynamicItems";
|
import { decorateIndexItems } from "./renderComponents";
|
||||||
import { renderComponentMap } from "./renderComponents";
|
|
||||||
import { jobs } from "./jobs";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Passive network observer.
|
* Passive network observer.
|
||||||
@@ -41,6 +39,8 @@ const MAX_PER_RESPONSE_TEXT_CHARS = 1500;
|
|||||||
let installed = false;
|
let installed = false;
|
||||||
let pendingFlush: ReturnType<typeof setTimeout> | null = null;
|
let pendingFlush: ReturnType<typeof setTimeout> | null = null;
|
||||||
let pendingDirty = false;
|
let pendingDirty = false;
|
||||||
|
/** Items persisted since the last flush — only these are pushed to the search layer. */
|
||||||
|
const pendingChangedItems = new Map<string, IndexItem>();
|
||||||
|
|
||||||
export function isPassiveObserverInstalled(): boolean {
|
export function isPassiveObserverInstalled(): boolean {
|
||||||
return installed;
|
return installed;
|
||||||
@@ -386,6 +386,7 @@ async function persistItems(items: IndexItem[]): Promise<void> {
|
|||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
try {
|
try {
|
||||||
await put(STORE_ID, item, item.id);
|
await put(STORE_ID, item, item.id);
|
||||||
|
pendingChangedItems.set(item.id, item);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn(
|
console.warn(
|
||||||
`[Passive Observer] Failed to persist item ${item.id}:`,
|
`[Passive Observer] Failed to persist item ${item.id}:`,
|
||||||
@@ -409,38 +410,20 @@ function scheduleFlush() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function flushDynamicItems(): Promise<void> {
|
async function flushDynamicItems(): Promise<void> {
|
||||||
|
if (pendingChangedItems.size === 0) return;
|
||||||
|
|
||||||
|
const rawChanged = Array.from(pendingChangedItems.values());
|
||||||
|
pendingChangedItems.clear();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const all = await loadAllStoredItems();
|
const decorated = decorateIndexItems(rawChanged);
|
||||||
const decorated = all.map((item) => {
|
mergeDynamicItems(decorated);
|
||||||
try {
|
|
||||||
const jobDef =
|
|
||||||
jobs[item.category] ||
|
|
||||||
Object.values(jobs).find((j) => j.id === item.category) ||
|
|
||||||
jobs[item.renderComponentId];
|
|
||||||
let renderComponent = item.renderComponent;
|
|
||||||
if (jobDef) {
|
|
||||||
renderComponent =
|
|
||||||
renderComponentMap[jobDef.renderComponentId] || renderComponent;
|
|
||||||
} else if (renderComponentMap[item.renderComponentId]) {
|
|
||||||
renderComponent = renderComponentMap[item.renderComponentId];
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const cloned = JSON.parse(JSON.stringify(item));
|
|
||||||
cloned.renderComponent = renderComponent;
|
|
||||||
return cloned;
|
|
||||||
} catch {
|
|
||||||
return { ...item, renderComponent };
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
return item;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
loadDynamicItems(decorated);
|
|
||||||
window.dispatchEvent(
|
window.dispatchEvent(
|
||||||
new CustomEvent("dynamic-items-updated", {
|
new CustomEvent("dynamic-items-updated", {
|
||||||
detail: {
|
detail: {
|
||||||
incremental: true,
|
incremental: true,
|
||||||
jobId: STORE_ID,
|
jobId: STORE_ID,
|
||||||
|
changedItems: decorated,
|
||||||
streaming: false,
|
streaming: false,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import AssessmentItem from "../components/items/AssessmentItem.svelte";
|
|||||||
import ForumItem from "../components/items/ForumItem.svelte";
|
import ForumItem from "../components/items/ForumItem.svelte";
|
||||||
import SubjectItem from "../components/items/SubjectItem.svelte";
|
import SubjectItem from "../components/items/SubjectItem.svelte";
|
||||||
import GenericItem from "../components/items/GenericItem.svelte";
|
import GenericItem from "../components/items/GenericItem.svelte";
|
||||||
|
import type { IndexItem } from "./types";
|
||||||
|
import { jobs } from "./jobs";
|
||||||
|
|
||||||
export const renderComponentMap: Record<string, typeof SvelteComponent> = {
|
export const renderComponentMap: Record<string, typeof SvelteComponent> = {
|
||||||
assessment: AssessmentItem as unknown as typeof SvelteComponent,
|
assessment: AssessmentItem as unknown as typeof SvelteComponent,
|
||||||
@@ -22,3 +24,37 @@ export const renderComponentMap: Record<string, typeof SvelteComponent> = {
|
|||||||
goal: GenericItem as unknown as typeof SvelteComponent,
|
goal: GenericItem as unknown as typeof SvelteComponent,
|
||||||
passive: GenericItem as unknown as typeof SvelteComponent,
|
passive: GenericItem as unknown as typeof SvelteComponent,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function resolveRenderComponent(item: IndexItem): typeof SvelteComponent | undefined {
|
||||||
|
const jobDef =
|
||||||
|
jobs[item.category] ||
|
||||||
|
Object.values(jobs).find((j) => j.id === item.category) ||
|
||||||
|
jobs[item.renderComponentId];
|
||||||
|
if (jobDef) {
|
||||||
|
return renderComponentMap[jobDef.renderComponentId] || item.renderComponent;
|
||||||
|
}
|
||||||
|
if (renderComponentMap[item.renderComponentId]) {
|
||||||
|
return renderComponentMap[item.renderComponentId];
|
||||||
|
}
|
||||||
|
return item.renderComponent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach render components and deep-clone items for search UI (Firefox XrayWrapper).
|
||||||
|
*/
|
||||||
|
export function decorateIndexItems(items: IndexItem[]): IndexItem[] {
|
||||||
|
return items.map((item) => {
|
||||||
|
try {
|
||||||
|
const renderComponent = resolveRenderComponent(item);
|
||||||
|
try {
|
||||||
|
const cloned = JSON.parse(JSON.stringify(item)) as IndexItem;
|
||||||
|
cloned.renderComponent = renderComponent;
|
||||||
|
return cloned;
|
||||||
|
} catch {
|
||||||
|
return { ...item, renderComponent };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -132,6 +132,7 @@ export async function hybridSearch(
|
|||||||
bm25Results: CombinedResult[],
|
bm25Results: CombinedResult[],
|
||||||
query: string,
|
query: string,
|
||||||
options: HybridSearchOptions = {},
|
options: HybridSearchOptions = {},
|
||||||
|
precomputedVectorResults?: VectorSearchResult[],
|
||||||
): Promise<CombinedResult[]> {
|
): Promise<CombinedResult[]> {
|
||||||
const opts = { ...DEFAULT_OPTIONS, ...options };
|
const opts = { ...DEFAULT_OPTIONS, ...options };
|
||||||
const trimmedQuery = query.trim().toLowerCase();
|
const trimmedQuery = query.trim().toLowerCase();
|
||||||
@@ -146,9 +147,10 @@ export async function hybridSearch(
|
|||||||
|
|
||||||
if (trimmedQuery.length > 2) {
|
if (trimmedQuery.length > 2) {
|
||||||
try {
|
try {
|
||||||
// Get more vector results than BM25 results to ensure coverage
|
const vectorTopK = opts.bm25TopK * 2;
|
||||||
// This allows us to find semantic matches that BM25 might have missed
|
const vectorSearchResults =
|
||||||
const vectorSearchResults = await searchVectors(trimmedQuery, opts.bm25TopK * 2);
|
precomputedVectorResults ??
|
||||||
|
(await searchVectors(trimmedQuery, vectorTopK));
|
||||||
|
|
||||||
// Create a map of item ID to vector similarity
|
// Create a map of item ID to vector similarity
|
||||||
const vectorMap = new Map<string, number>();
|
const vectorMap = new Map<string, number>();
|
||||||
@@ -249,14 +251,26 @@ export async function hybridSearchWithExpansion(
|
|||||||
const trimmedQuery = query.trim().toLowerCase();
|
const trimmedQuery = query.trim().toLowerCase();
|
||||||
const liveIndexIds = new Set(allItems.map((item) => item.id));
|
const liveIndexIds = new Set(allItems.map((item) => item.id));
|
||||||
|
|
||||||
// First, rerank BM25 results
|
|
||||||
const rerankedBm25 = await hybridSearch(bm25Results, query, options);
|
|
||||||
|
|
||||||
// If query is too short, skip vector expansion
|
|
||||||
if (trimmedQuery.length <= 2) {
|
if (trimmedQuery.length <= 2) {
|
||||||
return rerankedBm25;
|
return hybridSearch(bm25Results, query, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let vectorResults: VectorSearchResult[] = [];
|
||||||
|
try {
|
||||||
|
vectorResults = await searchVectors(trimmedQuery, opts.bm25TopK * 2);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[Hybrid Search] Vector search failed:", e);
|
||||||
|
return hybridSearch(bm25Results, query, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rerank BM25 results using the single vector pass above
|
||||||
|
const rerankedBm25 = await hybridSearch(
|
||||||
|
bm25Results,
|
||||||
|
query,
|
||||||
|
options,
|
||||||
|
vectorResults,
|
||||||
|
);
|
||||||
|
|
||||||
// For short / single-token queries vector expansion brings in too much
|
// For short / single-token queries vector expansion brings in too much
|
||||||
// noise (and is the main reason results "flicker" between adjacent
|
// noise (and is the main reason results "flicker" between adjacent
|
||||||
// keystrokes). Keep semantic recall for longer queries.
|
// keystrokes). Keep semantic recall for longer queries.
|
||||||
@@ -264,15 +278,6 @@ export async function hybridSearchWithExpansion(
|
|||||||
return rerankedBm25.slice(0, opts.finalLimit);
|
return rerankedBm25.slice(0, opts.finalLimit);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get vector search results
|
|
||||||
let vectorResults: VectorSearchResult[] = [];
|
|
||||||
try {
|
|
||||||
vectorResults = await searchVectors(trimmedQuery, opts.bm25TopK);
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("[Hybrid Search] Vector search failed:", e);
|
|
||||||
return rerankedBm25;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find vector results that weren't in BM25 results
|
// Find vector results that weren't in BM25 results
|
||||||
const bm25Ids = new Set(bm25Results.map(r => r.item.id));
|
const bm25Ids = new Set(bm25Results.map(r => r.item.id));
|
||||||
const vectorOnlyResults: CombinedResult[] = [];
|
const vectorOnlyResults: CombinedResult[] = [];
|
||||||
|
|||||||
@@ -101,36 +101,12 @@ if (typeof window !== 'undefined') {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createSearchIndexes() {
|
/** Rebuild Fuse when incremental delta exceeds this count. */
|
||||||
clearSearchCache();
|
export const INCREMENTAL_FUSE_REBUILD_THRESHOLD = 75;
|
||||||
const commands = getStaticCommands();
|
|
||||||
const dynamicItems = dedupeIndexItemsForSearch(getDynamicItems());
|
|
||||||
|
|
||||||
// Optimized command search options
|
export const DYNAMIC_FUSE_OPTIONS = {
|
||||||
const commandOptions = {
|
|
||||||
keys: ["text", "category", "keywords"],
|
|
||||||
includeScore: true,
|
|
||||||
includeMatches: true,
|
|
||||||
threshold: 0.35, // Slightly more permissive for better recall
|
|
||||||
minMatchCharLength: 2,
|
|
||||||
useExtendedSearch: false,
|
|
||||||
ignoreLocation: false,
|
|
||||||
findAllMatches: false, // Performance optimization
|
|
||||||
};
|
|
||||||
|
|
||||||
// Optimized dynamic content search options.
|
|
||||||
// The expanded corpus mixes structured entities (assessments, subjects)
|
|
||||||
// with free-form text (course content, notices, folio bodies, passive
|
|
||||||
// captures) so we list a broad set of metadata keys while keeping titles
|
|
||||||
// dominant in the ranking.
|
|
||||||
// NOTE: metadata.route is intentionally excluded. Raw API paths like
|
|
||||||
// `/seqta/student/load/message/people` should never influence ranking — they
|
|
||||||
// historically caused passive-capture support records to bubble up above
|
|
||||||
// real assessments when the user typed substrings that happened to appear in
|
|
||||||
// the path.
|
|
||||||
const dynamicOptions = {
|
|
||||||
keys: [
|
keys: [
|
||||||
{ name: "text", weight: 3 }, // Title is king
|
{ name: "text", weight: 3 },
|
||||||
{ name: "content", weight: 1 },
|
{ name: "content", weight: 1 },
|
||||||
{ name: "category", weight: 0.4 },
|
{ name: "category", weight: 0.4 },
|
||||||
{ name: "metadata.subjectName", weight: 1.6 },
|
{ name: "metadata.subjectName", weight: 1.6 },
|
||||||
@@ -153,14 +129,86 @@ export function createSearchIndexes() {
|
|||||||
ignoreLocation: true,
|
ignoreLocation: true,
|
||||||
findAllMatches: true,
|
findAllMatches: true,
|
||||||
shouldSort: true,
|
shouldSort: true,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export interface DynamicItemsUpdatedDetail {
|
||||||
|
incremental?: boolean;
|
||||||
|
fullRebuild?: boolean;
|
||||||
|
jobId?: string;
|
||||||
|
changedItems?: IndexItem[];
|
||||||
|
removedIds?: string[];
|
||||||
|
vectorUpdate?: boolean;
|
||||||
|
streaming?: boolean;
|
||||||
|
newItemCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDynamicContentFuse(
|
||||||
|
items: IndexItem[],
|
||||||
|
): Fuse<IndexItem> {
|
||||||
|
return new Fuse(
|
||||||
|
dedupeIndexItemsForSearch(items),
|
||||||
|
DYNAMIC_FUSE_OPTIONS,
|
||||||
|
) as Fuse<IndexItem>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply an incremental dynamic-item delta to an existing Fuse index.
|
||||||
|
* Returns null when a full rebuild is recommended.
|
||||||
|
*/
|
||||||
|
export function applyDynamicIndexDelta(
|
||||||
|
fuse: Fuse<IndexItem> | undefined,
|
||||||
|
idToItemMap: Map<string, IndexItem>,
|
||||||
|
detail: DynamicItemsUpdatedDetail,
|
||||||
|
): Fuse<IndexItem> | null {
|
||||||
|
const changedItems = detail.changedItems ?? [];
|
||||||
|
const removedIds = detail.removedIds ?? [];
|
||||||
|
const deltaSize = changedItems.length + removedIds.length;
|
||||||
|
|
||||||
|
if (
|
||||||
|
detail.fullRebuild ||
|
||||||
|
!fuse ||
|
||||||
|
idToItemMap.size === 0 ||
|
||||||
|
deltaSize === 0 ||
|
||||||
|
deltaSize > INCREMENTAL_FUSE_REBUILD_THRESHOLD
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const id of removedIds) {
|
||||||
|
fuse.remove((item) => item.id === id);
|
||||||
|
idToItemMap.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of changedItems) {
|
||||||
|
fuse.remove((existing) => existing.id === item.id);
|
||||||
|
fuse.add(item);
|
||||||
|
idToItemMap.set(item.id, item);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearSearchCache();
|
||||||
|
return fuse;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSearchIndexes() {
|
||||||
|
clearSearchCache();
|
||||||
|
const commands = getStaticCommands();
|
||||||
|
const dynamicItems = dedupeIndexItemsForSearch(getDynamicItems());
|
||||||
|
|
||||||
|
// Optimized command search options
|
||||||
|
const commandOptions = {
|
||||||
|
keys: ["text", "category", "keywords"],
|
||||||
|
includeScore: true,
|
||||||
|
includeMatches: true,
|
||||||
|
threshold: 0.35, // Slightly more permissive for better recall
|
||||||
|
minMatchCharLength: 2,
|
||||||
|
useExtendedSearch: false,
|
||||||
|
ignoreLocation: false,
|
||||||
|
findAllMatches: false, // Performance optimization
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
commandsFuse: new Fuse(commands, commandOptions) as Fuse<StaticCommandItem>,
|
commandsFuse: new Fuse(commands, commandOptions) as Fuse<StaticCommandItem>,
|
||||||
dynamicContentFuse: new Fuse(
|
dynamicContentFuse: createDynamicContentFuse(dynamicItems),
|
||||||
dynamicItems,
|
|
||||||
dynamicOptions,
|
|
||||||
) as Fuse<IndexItem>,
|
|
||||||
commands,
|
commands,
|
||||||
dynamicItems,
|
dynamicItems,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,12 +16,29 @@ export interface DynamicContentItem {
|
|||||||
let dynamicItems: IndexItem[] = [];
|
let dynamicItems: IndexItem[] = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads a new set of dynamic items.
|
* Loads a new set of dynamic items (full replace).
|
||||||
*/
|
*/
|
||||||
export function loadDynamicItems(items: IndexItem[]) {
|
export function loadDynamicItems(items: IndexItem[]) {
|
||||||
dynamicItems = items;
|
dynamicItems = items;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge changed items and remove deleted ids without reloading the full corpus.
|
||||||
|
*/
|
||||||
|
export function mergeDynamicItems(
|
||||||
|
changedItems: IndexItem[],
|
||||||
|
removedIds: string[] = [],
|
||||||
|
): void {
|
||||||
|
if (changedItems.length === 0 && removedIds.length === 0) return;
|
||||||
|
|
||||||
|
const removeSet = new Set(removedIds);
|
||||||
|
const changeMap = new Map(changedItems.map((item) => [item.id, item]));
|
||||||
|
const kept = dynamicItems.filter(
|
||||||
|
(item) => !removeSet.has(item.id) && !changeMap.has(item.id),
|
||||||
|
);
|
||||||
|
dynamicItems = [...kept, ...changedItems];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns all currently loaded dynamic items.
|
* Returns all currently loaded dynamic items.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -310,8 +310,6 @@
|
|||||||
|
|
||||||
y: { type: "tween", duration: 600, easing: cubicInOut },
|
y: { type: "tween", duration: 600, easing: cubicInOut },
|
||||||
|
|
||||||
height: { type: "tween", duration: 600, easing: cubicInOut },
|
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import renderSvelte from "@/interface/main";
|
|
||||||
import { ThemeManager } from "@/plugins/built-in/themes/theme-manager";
|
import { ThemeManager } from "@/plugins/built-in/themes/theme-manager";
|
||||||
import { unmount } from "svelte";
|
import { unmount } from "svelte";
|
||||||
import themeCreator from "@/interface/pages/themeCreator.svelte";
|
|
||||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||||
|
|
||||||
let themeCreatorSvelteApp: any = null;
|
let themeCreatorSvelteApp: any = null;
|
||||||
@@ -11,10 +9,15 @@ let themeCreatorSvelteApp: any = null;
|
|||||||
* @param themeID - The ID of the theme to load in the Theme Creator
|
* @param themeID - The ID of the theme to load in the Theme Creator
|
||||||
* @returns void
|
* @returns void
|
||||||
*/
|
*/
|
||||||
export function OpenThemeCreator(themeID: string = "") {
|
export async function OpenThemeCreator(themeID: string = "") {
|
||||||
CloseThemeCreator();
|
CloseThemeCreator();
|
||||||
|
|
||||||
// Only store original color if we're not editing an existing theme
|
const [{ default: renderSvelte }, { default: themeCreator }] =
|
||||||
|
await Promise.all([
|
||||||
|
import("@/interface/main"),
|
||||||
|
import("@/interface/pages/themeCreator.svelte"),
|
||||||
|
]);
|
||||||
|
|
||||||
localStorage.setItem("themeCreatorOpen", "true");
|
localStorage.setItem("themeCreatorOpen", "true");
|
||||||
if (!themeID) {
|
if (!themeID) {
|
||||||
localStorage.setItem("originalPreviewColor", settingsState.selectedColor);
|
localStorage.setItem("originalPreviewColor", settingsState.selectedColor);
|
||||||
@@ -34,7 +37,6 @@ export function OpenThemeCreator(themeID: string = "") {
|
|||||||
const mainContent = document.querySelector("#container") as HTMLDivElement;
|
const mainContent = document.querySelector("#container") as HTMLDivElement;
|
||||||
if (mainContent) mainContent.style.width = `calc(100% - ${width})`;
|
if (mainContent) mainContent.style.width = `calc(100% - ${width})`;
|
||||||
|
|
||||||
// close button
|
|
||||||
const closeButton = document.createElement("button");
|
const closeButton = document.createElement("button");
|
||||||
closeButton.classList.add("themeCloseButton");
|
closeButton.classList.add("themeCloseButton");
|
||||||
closeButton.textContent = "×";
|
closeButton.textContent = "×";
|
||||||
@@ -92,7 +94,6 @@ export function OpenThemeCreator(themeID: string = "") {
|
|||||||
* @returns void
|
* @returns void
|
||||||
*/
|
*/
|
||||||
export function CloseThemeCreator() {
|
export function CloseThemeCreator() {
|
||||||
// Remove the stored flag
|
|
||||||
localStorage.removeItem("themeCreatorOpen");
|
localStorage.removeItem("themeCreatorOpen");
|
||||||
|
|
||||||
const themeCreator = document.getElementById("themeCreator");
|
const themeCreator = document.getElementById("themeCreator");
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { eventManager } from "@/seqta/utils/listeners/EventManager";
|
|||||||
import ReactFiber from "@/seqta/utils/ReactFiber";
|
import ReactFiber from "@/seqta/utils/ReactFiber";
|
||||||
import browser from "webextension-polyfill";
|
import browser from "webextension-polyfill";
|
||||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||||
|
import type { SettingsState } from "@/types/storage";
|
||||||
|
|
||||||
function createSEQTAAPI(): SEQTAAPI {
|
function createSEQTAAPI(): SEQTAAPI {
|
||||||
return {
|
return {
|
||||||
@@ -149,29 +150,27 @@ function createSettingsAPI<T extends PluginSettings>(
|
|||||||
|
|
||||||
settingsWithMeta.loaded = loaded;
|
settingsWithMeta.loaded = loaded;
|
||||||
|
|
||||||
// Listen for storage changes and update settingsWithMeta
|
const handleSettingsChange = (newValue: unknown) => {
|
||||||
const handleStorageChange = (
|
if (!newValue || typeof newValue !== "object") return;
|
||||||
changes: { [key: string]: browser.Storage.StorageChange },
|
|
||||||
area: string,
|
|
||||||
) => {
|
|
||||||
if (area !== "local" || !(storageKey in changes)) return;
|
|
||||||
|
|
||||||
const newValue = changes[storageKey].newValue as
|
const newSettings = newValue as Partial<Record<keyof T, any>>;
|
||||||
| Partial<Record<keyof T, any>>
|
for (const key in newSettings) {
|
||||||
| undefined;
|
|
||||||
if (!newValue) return;
|
|
||||||
|
|
||||||
for (const key in newValue) {
|
|
||||||
const typedKey = key as keyof T;
|
const typedKey = key as keyof T;
|
||||||
settingsWithMeta[typedKey] = newValue[typedKey];
|
settingsWithMeta[typedKey] = newSettings[typedKey];
|
||||||
listeners.get(typedKey)?.forEach((cb) => cb(newValue[typedKey]));
|
listeners.get(typedKey)?.forEach((cb) => cb(newSettings[typedKey]));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
browser.storage.onChanged.addListener(handleStorageChange);
|
settingsState.register(
|
||||||
|
storageKey as keyof SettingsState,
|
||||||
|
handleSettingsChange,
|
||||||
|
);
|
||||||
|
|
||||||
const dispose = () => {
|
const dispose = () => {
|
||||||
browser.storage.onChanged.removeListener(handleStorageChange);
|
settingsState.unregister(
|
||||||
|
storageKey as keyof SettingsState,
|
||||||
|
handleSettingsChange,
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const proxy = new Proxy(settingsWithMeta, {
|
const proxy = new Proxy(settingsWithMeta, {
|
||||||
@@ -241,29 +240,22 @@ function createStorageAPI<T = any>(
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// Listen for storage changes
|
|
||||||
const handleStorageChange = (
|
const handleStorageChange = (
|
||||||
changes: { [key: string]: any },
|
newValue: unknown,
|
||||||
area: string,
|
_oldValue: unknown,
|
||||||
|
key: string,
|
||||||
) => {
|
) => {
|
||||||
if (area === "local") {
|
if (!key.startsWith(prefix)) return;
|
||||||
Object.entries(changes).forEach(([key, change]) => {
|
|
||||||
if (key.startsWith(prefix)) {
|
|
||||||
const shortKey = key.slice(prefix.length);
|
|
||||||
cache[shortKey] = change.newValue;
|
|
||||||
|
|
||||||
// Notify listeners
|
const shortKey = key.slice(prefix.length);
|
||||||
listeners
|
cache[shortKey] = newValue;
|
||||||
.get(shortKey)
|
listeners.get(shortKey)?.forEach((callback) => callback(newValue));
|
||||||
?.forEach((callback) => callback(change.newValue));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
browser.storage.onChanged.addListener(handleStorageChange);
|
|
||||||
|
settingsState.registerGlobal(handleStorageChange);
|
||||||
|
|
||||||
const dispose = () => {
|
const dispose = () => {
|
||||||
browser.storage.onChanged.removeListener(handleStorageChange);
|
settingsState.unregisterGlobal(handleStorageChange);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create the proxy for direct property access
|
// Create the proxy for direct property access
|
||||||
|
|||||||
+44
-11
@@ -23,6 +23,23 @@ interface StorageChange<T = any> {
|
|||||||
newValue?: T;
|
newValue?: T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Phased plugin startup: critical UI first, light DOM next, heavy plugins last. */
|
||||||
|
const PLUGIN_START_PHASES: readonly string[][] = [
|
||||||
|
["themes", "animated-background"],
|
||||||
|
[
|
||||||
|
"timetable",
|
||||||
|
"timetableEdit",
|
||||||
|
"notificationCollector",
|
||||||
|
"enhanced-navigation",
|
||||||
|
"assessments-overview",
|
||||||
|
"assessments-average",
|
||||||
|
"messageFolders",
|
||||||
|
"profile-picture",
|
||||||
|
"background-music",
|
||||||
|
],
|
||||||
|
["global-search", "grade-analytics"],
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Singleton class responsible for the entire lifecycle of plugins.
|
* Singleton class responsible for the entire lifecycle of plugins.
|
||||||
* This includes registration, starting, stopping, event dispatching,
|
* This includes registration, starting, stopping, event dispatching,
|
||||||
@@ -215,25 +232,41 @@ export class PluginManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private async startPluginPhase(pluginIds: string[]): Promise<void> {
|
||||||
* Attempts to start all registered plugins.
|
const registeredIds = new Set(this.plugins.keys());
|
||||||
* Errors during the start of individual plugins are caught and logged,
|
const idsToStart = pluginIds.filter((id) => registeredIds.has(id));
|
||||||
* allowing other plugins to attempt to start.
|
|
||||||
*
|
const startPromises = idsToStart.map((id) =>
|
||||||
* @returns {Promise<void>} A promise that resolves when all plugins have attempted to start.
|
|
||||||
* It uses `Promise.allSettled` to wait for all start operations.
|
|
||||||
*/
|
|
||||||
public async startAllPlugins(): Promise<void> {
|
|
||||||
const startPromises = Array.from(this.plugins.keys()).map((id) =>
|
|
||||||
this.startPlugin(id).catch((error) => {
|
this.startPlugin(id).catch((error) => {
|
||||||
console.error(`Failed to start plugin "${id}":`, error);
|
console.error(`Failed to start plugin "${id}":`, error);
|
||||||
return Promise.reject(error); // Still reject to indicate failure for this specific plugin if needed by caller
|
return Promise.reject(error);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
await Promise.allSettled(startPromises);
|
await Promise.allSettled(startPromises);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempts to start all registered plugins in phased order.
|
||||||
|
* Errors during the start of individual plugins are caught and logged,
|
||||||
|
* allowing other plugins to attempt to start.
|
||||||
|
*
|
||||||
|
* @returns {Promise<void>} A promise that resolves when all plugins have attempted to start.
|
||||||
|
*/
|
||||||
|
public async startAllPlugins(): Promise<void> {
|
||||||
|
for (const phase of PLUGIN_START_PHASES) {
|
||||||
|
await this.startPluginPhase(phase);
|
||||||
|
}
|
||||||
|
|
||||||
|
const phasedIds = new Set(PLUGIN_START_PHASES.flat());
|
||||||
|
const remainingIds = Array.from(this.plugins.keys()).filter(
|
||||||
|
(id) => !phasedIds.has(id),
|
||||||
|
);
|
||||||
|
if (remainingIds.length > 0) {
|
||||||
|
await this.startPluginPhase(remainingIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stops a specific plugin by its ID.
|
* Stops a specific plugin by its ID.
|
||||||
* This involves:
|
* This involves:
|
||||||
|
|||||||
@@ -39,8 +39,6 @@ pluginManager.registerPlugin(enhancedNavigationPlugin);
|
|||||||
pluginManager.registerPlugin(globalSearchPluginLazy);
|
pluginManager.registerPlugin(globalSearchPluginLazy);
|
||||||
pluginManager.registerPlugin(gradeAnalyticsPluginLazy);
|
pluginManager.registerPlugin(gradeAnalyticsPluginLazy);
|
||||||
|
|
||||||
export { init as Monofile } from "./monofile";
|
|
||||||
|
|
||||||
export async function initializePlugins(): Promise<void> {
|
export async function initializePlugins(): Promise<void> {
|
||||||
await pluginManager.startAllPlugins();
|
await pluginManager.startAllPlugins();
|
||||||
}
|
}
|
||||||
|
|||||||
+30
-15
@@ -26,7 +26,6 @@ import {
|
|||||||
updateEngageHomeMenuActive,
|
updateEngageHomeMenuActive,
|
||||||
} from "@/seqta/utils/Loaders/LoadEngageHomePage";
|
} from "@/seqta/utils/Loaders/LoadEngageHomePage";
|
||||||
import { loadHomePage } from "@/seqta/utils/Loaders/LoadHomePage";
|
import { loadHomePage } from "@/seqta/utils/Loaders/LoadHomePage";
|
||||||
import { loadAnalyticsPage } from "@/plugins/built-in/gradeAnalytics/loadAnalyticsPage";
|
|
||||||
import { runStartupPopupQueue } from "@/seqta/utils/Openers/StartupPopupQueue";
|
import { runStartupPopupQueue } from "@/seqta/utils/Openers/StartupPopupQueue";
|
||||||
|
|
||||||
import { updateTimetableTimes } from "@/seqta/utils/updateTimetableTimes";
|
import { updateTimetableTimes } from "@/seqta/utils/updateTimetableTimes";
|
||||||
@@ -337,7 +336,11 @@ async function handleSublink(sublink: string | undefined): Promise<void> {
|
|||||||
break;
|
break;
|
||||||
case "analytics":
|
case "analytics":
|
||||||
console.info("[BetterSEQTA+] Started Init (Analytics)");
|
console.info("[BetterSEQTA+] Started Init (Analytics)");
|
||||||
if (settingsState.onoff) void loadAnalyticsPage();
|
if (settingsState.onoff) {
|
||||||
|
void import("@/plugins/built-in/gradeAnalytics/loadAnalyticsPage").then(
|
||||||
|
(m) => m.loadAnalyticsPage(),
|
||||||
|
);
|
||||||
|
}
|
||||||
finishLoad();
|
finishLoad();
|
||||||
break;
|
break;
|
||||||
case undefined:
|
case undefined:
|
||||||
@@ -488,25 +491,37 @@ async function handleReports(node: Element): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function CheckNoticeTextColour(notice: any) {
|
function CheckNoticeTextColour(notice: Element) {
|
||||||
eventManager.register(
|
const adjustNoticeColor = (node: Element) => {
|
||||||
"noticeAdded",
|
const hex = (node as HTMLElement).style.cssText.split(" ")[1];
|
||||||
{
|
|
||||||
elementType: "div",
|
|
||||||
className: "notice",
|
|
||||||
parentElement: notice,
|
|
||||||
},
|
|
||||||
(node) => {
|
|
||||||
var hex = (node as HTMLElement).style.cssText.split(" ")[1];
|
|
||||||
if (hex) {
|
if (hex) {
|
||||||
const hex1 = hex.slice(0, -1);
|
const hex1 = hex.slice(0, -1);
|
||||||
var threshold = GetThresholdOfColor(hex1);
|
const threshold = GetThresholdOfColor(hex1);
|
||||||
if (settingsState.DarkMode && threshold < 100) {
|
if (settingsState.DarkMode && threshold < 100) {
|
||||||
(node as HTMLElement).style.cssText = "--color: undefined;";
|
(node as HTMLElement).style.cssText = "--color: undefined;";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
};
|
||||||
);
|
|
||||||
|
for (const node of notice.querySelectorAll("div.notice")) {
|
||||||
|
adjustNoticeColor(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
const observer = new MutationObserver((mutations) => {
|
||||||
|
for (const mutation of mutations) {
|
||||||
|
for (const added of mutation.addedNodes) {
|
||||||
|
if (!(added instanceof Element)) continue;
|
||||||
|
if (added.matches("div.notice")) {
|
||||||
|
adjustNoticeColor(added);
|
||||||
|
}
|
||||||
|
for (const node of added.querySelectorAll("div.notice")) {
|
||||||
|
adjustNoticeColor(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(notice, { childList: true, subtree: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
function watchForEngageLogin() {
|
function watchForEngageLogin() {
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export { init as Monofile } from "./monofile";
|
||||||
|
|
||||||
|
export async function initializePlugins(): Promise<void> {
|
||||||
|
const { pluginManager } = await import("./index");
|
||||||
|
await pluginManager.startAllPlugins();
|
||||||
|
}
|
||||||
@@ -16,10 +16,12 @@ import { updateAllColors } from "./colors/Manager";
|
|||||||
import { delay } from "@/seqta/utils/delay";
|
import { delay } from "@/seqta/utils/delay";
|
||||||
|
|
||||||
let cachedUserInfo: any = null;
|
let cachedUserInfo: any = null;
|
||||||
|
let userInfoFetchPromise: Promise<any> | null = null;
|
||||||
let userInfoCacheListenersAttached = false;
|
let userInfoCacheListenersAttached = false;
|
||||||
|
|
||||||
export function invalidateCachedUserInfo(): void {
|
export function invalidateCachedUserInfo(): void {
|
||||||
cachedUserInfo = null;
|
cachedUserInfo = null;
|
||||||
|
userInfoFetchPromise = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function attachUserInfoCacheInvalidation(): void {
|
function attachUserInfoCacheInvalidation(): void {
|
||||||
@@ -48,6 +50,11 @@ export async function getUserInfo(options?: { validateSession?: boolean }) {
|
|||||||
return cachedUserInfo;
|
return cachedUserInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (userInfoFetchPromise && !options?.validateSession) {
|
||||||
|
return userInfoFetchPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchUserInfo = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${location.origin}/seqta/student/login`, {
|
const response = await fetch(`${location.origin}/seqta/student/login`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -85,7 +92,19 @@ export async function getUserInfo(options?: { validateSession?: boolean }) {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[BetterSEQTA+] Failed to get user info:", error);
|
console.error("[BetterSEQTA+] Failed to get user info:", error);
|
||||||
throw error;
|
throw error;
|
||||||
|
} finally {
|
||||||
|
if (!options?.validateSession) {
|
||||||
|
userInfoFetchPromise = null;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (options?.validateSession) {
|
||||||
|
return fetchUserInfo();
|
||||||
|
}
|
||||||
|
|
||||||
|
userInfoFetchPromise = fetchUserInfo();
|
||||||
|
return userInfoFetchPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function AddBetterSEQTAElements() {
|
export async function AddBetterSEQTAElements() {
|
||||||
@@ -115,11 +134,7 @@ export async function AddBetterSEQTAElements() {
|
|||||||
menuList.insertBefore(fragment, menuList.firstChild);
|
menuList.insertBefore(fragment, menuList.firstChild);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await Promise.all([
|
await Promise.all([appendBackgroundToUI(), handleUserInfoAndStudentData()]);
|
||||||
appendBackgroundToUI(),
|
|
||||||
handleUserInfo(),
|
|
||||||
handleStudentData(),
|
|
||||||
]);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[BetterSEQTA+] Failed to initialize UI elements:", error);
|
console.error("[BetterSEQTA+] Failed to initialize UI elements:", error);
|
||||||
}
|
}
|
||||||
@@ -149,11 +164,26 @@ function createHomeButton(fragment: DocumentFragment, _: HTMLElement) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleUserInfo() {
|
async function handleUserInfoAndStudentData() {
|
||||||
try {
|
try {
|
||||||
updateUserInfo(await getUserInfo());
|
const [userInfo, studentResponse] = await Promise.all([
|
||||||
|
getUserInfo(),
|
||||||
|
fetch(`${location.origin}/seqta/student/load/message/people`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json; charset=utf-8",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ mode: "student" }),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
updateUserInfo(userInfo);
|
||||||
|
await updateStudentInfo((await studentResponse.json()).payload, userInfo);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[BetterSEQTA+] Failed to handle user info:", error);
|
console.error(
|
||||||
|
"[BetterSEQTA+] Failed to handle user info and student data:",
|
||||||
|
error,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,27 +239,7 @@ function updateUserInfo(info: {
|
|||||||
.appendChild(document.getElementsByClassName("logout")[0]);
|
.appendChild(document.getElementsByClassName("logout")[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleStudentData() {
|
async function updateStudentInfo(students: any, info: Awaited<ReturnType<typeof getUserInfo>>) {
|
||||||
try {
|
|
||||||
const response = await fetch(
|
|
||||||
`${location.origin}/seqta/student/load/message/people`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json; charset=utf-8",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ mode: "student" }),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
await updateStudentInfo((await response.json()).payload);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[BetterSEQTA+] Failed to handle student data:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function updateStudentInfo(students: any) {
|
|
||||||
const info = await getUserInfo();
|
|
||||||
const index = students.findIndex(
|
const index = students.findIndex(
|
||||||
(person: any) =>
|
(person: any) =>
|
||||||
person.firstname == info.userDesc.split(" ")[0] &&
|
person.firstname == info.userDesc.split(" ")[0] &&
|
||||||
|
|||||||
@@ -1,26 +1,27 @@
|
|||||||
import renderSvelte from "@/interface/main";
|
|
||||||
import Store from "@/interface/pages/store.svelte";
|
|
||||||
|
|
||||||
import { unmount } from "svelte";
|
import { unmount } from "svelte";
|
||||||
|
|
||||||
let remove: () => void;
|
let remove: () => void;
|
||||||
|
|
||||||
export function OpenStorePage() {
|
export async function OpenStorePage(): Promise<void> {
|
||||||
remove = renderStore();
|
remove = await renderStore();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderStore() {
|
export async function renderStore() {
|
||||||
|
const [{ default: renderSvelte }, { default: Store }] = await Promise.all([
|
||||||
|
import("@/interface/main"),
|
||||||
|
import("@/interface/pages/store.svelte"),
|
||||||
|
]);
|
||||||
|
|
||||||
const container = document.querySelector("#container");
|
const container = document.querySelector("#container");
|
||||||
if (!container) {
|
if (!container) {
|
||||||
throw new Error("Container not found");
|
throw new Error("Container not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Avoid stacking multiple store roots if opened repeatedly without close.
|
|
||||||
document.getElementById("store")?.remove();
|
document.getElementById("store")?.remove();
|
||||||
|
|
||||||
const child = document.createElement("div");
|
const child = document.createElement("div");
|
||||||
child.id = "store";
|
child.id = "store";
|
||||||
container!.appendChild(child);
|
container.appendChild(child);
|
||||||
|
|
||||||
const shadow = child.attachShadow({ mode: "open" });
|
const shadow = child.attachShadow({ mode: "open" });
|
||||||
const app = renderSvelte(Store, shadow);
|
const app = renderSvelte(Store, shadow);
|
||||||
|
|||||||
@@ -3,11 +3,10 @@ import {
|
|||||||
closeExtensionPopup,
|
closeExtensionPopup,
|
||||||
SettingsClicked,
|
SettingsClicked,
|
||||||
} from "../Closers/closeExtensionPopup";
|
} from "../Closers/closeExtensionPopup";
|
||||||
import renderSvelte from "@/interface/main";
|
|
||||||
import { SettingsResizer } from "@/seqta/ui/SettingsResizer";
|
import { SettingsResizer } from "@/seqta/ui/SettingsResizer";
|
||||||
import Settings from "@/interface/pages/settings.svelte";
|
|
||||||
|
|
||||||
let isSettingsRendered = false;
|
let isSettingsRendered = false;
|
||||||
|
let settingsLoadPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
function extensionOutsideClickHandler(extensionPopup: HTMLElement) {
|
function extensionOutsideClickHandler(extensionPopup: HTMLElement) {
|
||||||
return (event: MouseEvent) => {
|
return (event: MouseEvent) => {
|
||||||
@@ -38,21 +37,39 @@ export function addExtensionSettings() {
|
|||||||
(extensionContainer ?? document.body).addEventListener("click", handler, false);
|
(extensionContainer ?? document.body).addEventListener("click", handler, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderSettingsIfNeeded() {
|
async function loadSettingsUi(extensionPopup: HTMLElement): Promise<void> {
|
||||||
|
if (isSettingsRendered) return;
|
||||||
|
|
||||||
|
const [{ default: renderSvelte }, { default: Settings }] = await Promise.all([
|
||||||
|
import("@/interface/main"),
|
||||||
|
import("@/interface/pages/settings.svelte"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const shadow = extensionPopup.attachShadow({ mode: "open" });
|
||||||
|
const mount = () => renderSvelte(Settings, shadow);
|
||||||
|
|
||||||
|
if ("requestIdleCallback" in window) {
|
||||||
|
requestIdleCallback(mount);
|
||||||
|
} else {
|
||||||
|
mount();
|
||||||
|
}
|
||||||
|
|
||||||
|
isSettingsRendered = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renderSettingsIfNeeded(): Promise<void> {
|
||||||
if (isSettingsRendered) return;
|
if (isSettingsRendered) return;
|
||||||
|
|
||||||
const extensionPopup = document.getElementById("ExtensionPopup");
|
const extensionPopup = document.getElementById("ExtensionPopup");
|
||||||
if (!extensionPopup) return;
|
if (!extensionPopup) return;
|
||||||
|
|
||||||
try {
|
if (!settingsLoadPromise) {
|
||||||
const shadow = extensionPopup.attachShadow({ mode: "open" });
|
settingsLoadPromise = loadSettingsUi(extensionPopup).catch((err) => {
|
||||||
if ('requestIdleCallback' in window) {
|
settingsLoadPromise = null;
|
||||||
requestIdleCallback(() => renderSvelte(Settings, shadow));
|
|
||||||
} else {
|
|
||||||
renderSvelte(Settings, shadow);
|
|
||||||
}
|
|
||||||
isSettingsRendered = true;
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await settingsLoadPromise;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||||
import { animate } from "motion";
|
import { animate } from "motion";
|
||||||
|
|
||||||
import { settingsPopup } from "@/interface/hooks/SettingsPopup";
|
import { settingsPopup } from "@/seqta/utils/settingsPopup";
|
||||||
|
|
||||||
export let SettingsClicked = false;
|
export let SettingsClicked = false;
|
||||||
|
|
||||||
|
|||||||
@@ -785,7 +785,7 @@ export async function OpenThemeOfTheMonthPopup(
|
|||||||
card.querySelector(".themeOfTheMonthCardPrimary")?.addEventListener("click", () => {
|
card.querySelector(".themeOfTheMonthCardPrimary")?.addEventListener("click", () => {
|
||||||
settingsState.themeOfTheMonthDismissedMonth = entry.month;
|
settingsState.themeOfTheMonthDismissedMonth = entry.month;
|
||||||
dismissWithCleanup();
|
dismissWithCleanup();
|
||||||
openThemeStoreWithHighlight(linkedThemeId!);
|
void openThemeStoreWithHighlight(linkedThemeId!);
|
||||||
});
|
});
|
||||||
|
|
||||||
const openDontShowConfirm = () => {
|
const openDontShowConfirm = () => {
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ const OPTIONAL_UNSET_MEANS_DEFAULT_KEYS = [
|
|||||||
"profile_picture_revision",
|
"profile_picture_revision",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
let defaultsEnsured = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Flat default map in upload shape (plugin-format only; no legacy keys).
|
* Flat default map in upload shape (plugin-format only; no legacy keys).
|
||||||
*/
|
*/
|
||||||
@@ -76,6 +78,8 @@ function mergePluginSettingsDefaults(
|
|||||||
* Never overwrites existing values. Missing plugin settings respect legacy keys.
|
* Never overwrites existing values. Missing plugin settings respect legacy keys.
|
||||||
*/
|
*/
|
||||||
export async function ensureSyncableStorageDefaults(): Promise<void> {
|
export async function ensureSyncableStorageDefaults(): Promise<void> {
|
||||||
|
if (defaultsEnsured) return;
|
||||||
|
|
||||||
const existing = await browser.storage.local.get();
|
const existing = await browser.storage.local.get();
|
||||||
const migratedFromExisting = migrateLegacyToPluginSettings({
|
const migratedFromExisting = migrateLegacyToPluginSettings({
|
||||||
...existing,
|
...existing,
|
||||||
@@ -101,4 +105,6 @@ export async function ensureSyncableStorageDefaults(): Promise<void> {
|
|||||||
if (Object.keys(patch).length > 0) {
|
if (Object.keys(patch).length > 0) {
|
||||||
await browser.storage.local.set(patch);
|
await browser.storage.local.set(patch);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
defaultsEnsured = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ interface EventListenerOptions {
|
|||||||
textContent?: string;
|
textContent?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
id?: string;
|
id?: string;
|
||||||
|
selector?: string;
|
||||||
customCheck?: (element: Element) => boolean;
|
customCheck?: (element: Element) => boolean;
|
||||||
once?: boolean;
|
once?: boolean;
|
||||||
parentElement?: Element;
|
parentElement?: Element;
|
||||||
@@ -20,6 +21,7 @@ class EventManager {
|
|||||||
private listeners: Map<string, EventListener[]> = new Map();
|
private listeners: Map<string, EventListener[]> = new Map();
|
||||||
private mutationObservers: Map<Element, MutationObserver> = new Map();
|
private mutationObservers: Map<Element, MutationObserver> = new Map();
|
||||||
private pendingElements: Set<Element> = new Set();
|
private pendingElements: Set<Element> = new Set();
|
||||||
|
private firedOnceIds: Set<string> = new Set();
|
||||||
private throttleTimeout: number = 5; // 5ms throttle
|
private throttleTimeout: number = 5; // 5ms throttle
|
||||||
private throttleTimer: number | undefined;
|
private throttleTimer: number | undefined;
|
||||||
private chunkSize: number = 50; // Process 50 elements per chunk
|
private chunkSize: number = 50; // Process 50 elements per chunk
|
||||||
@@ -58,6 +60,7 @@ class EventManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private buildSelector(options: EventListenerOptions): string | null {
|
private buildSelector(options: EventListenerOptions): string | null {
|
||||||
|
if (options.selector) return options.selector;
|
||||||
if (options.textContent || options.customCheck) return null;
|
if (options.textContent || options.customCheck) return null;
|
||||||
|
|
||||||
let selector = options.elementType || "";
|
let selector = options.elementType || "";
|
||||||
@@ -71,6 +74,23 @@ class EventManager {
|
|||||||
return selector.trim() || null;
|
return selector.trim() || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getElementsToCheck(
|
||||||
|
element: Element,
|
||||||
|
options: EventListenerOptions,
|
||||||
|
): Element[] {
|
||||||
|
const selector = this.buildSelector(options);
|
||||||
|
if (!selector) return [element];
|
||||||
|
|
||||||
|
const targets = new Set<Element>();
|
||||||
|
if (element.matches(selector)) {
|
||||||
|
targets.add(element);
|
||||||
|
}
|
||||||
|
for (const match of element.querySelectorAll(selector)) {
|
||||||
|
targets.add(match);
|
||||||
|
}
|
||||||
|
return Array.from(targets);
|
||||||
|
}
|
||||||
|
|
||||||
private async scanExistingElements(
|
private async scanExistingElements(
|
||||||
options: EventListenerOptions,
|
options: EventListenerOptions,
|
||||||
callback: (element: Element) => void,
|
callback: (element: Element) => void,
|
||||||
@@ -174,10 +194,17 @@ class EventManager {
|
|||||||
private async checkElement(element: Element): Promise<void> {
|
private async checkElement(element: Element): Promise<void> {
|
||||||
for (const [event, listeners] of this.listeners.entries()) {
|
for (const [event, listeners] of this.listeners.entries()) {
|
||||||
for (const { id, options, callback } of listeners) {
|
for (const { id, options, callback } of listeners) {
|
||||||
if (this.matchesOptions(element, options)) {
|
if (options.once && this.firedOnceIds.has(id)) continue;
|
||||||
callback(element);
|
|
||||||
|
const targets = this.getElementsToCheck(element, options);
|
||||||
|
for (const target of targets) {
|
||||||
|
if (!this.matchesOptions(target, options)) continue;
|
||||||
|
|
||||||
|
callback(target);
|
||||||
if (options.once) {
|
if (options.once) {
|
||||||
|
this.firedOnceIds.add(id);
|
||||||
this.unregisterById(event, id);
|
this.unregisterById(event, id);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,15 +6,20 @@ import {
|
|||||||
OpenMenuOptions,
|
OpenMenuOptions,
|
||||||
} from "@/seqta/utils/Openers/OpenMenuOptions";
|
} from "@/seqta/utils/Openers/OpenMenuOptions";
|
||||||
|
|
||||||
import { ThemeManager } from "@/plugins/built-in/themes/theme-manager";
|
|
||||||
import {
|
|
||||||
CloseThemeCreator,
|
|
||||||
OpenThemeCreator,
|
|
||||||
} from "@/plugins/built-in/themes/ThemeCreator";
|
|
||||||
import sendThemeUpdate from "@/seqta/utils/sendThemeUpdate";
|
import sendThemeUpdate from "@/seqta/utils/sendThemeUpdate";
|
||||||
import hideSensitiveContent from "@/seqta/ui/dev/hideSensitiveContent";
|
import hideSensitiveContent from "@/seqta/ui/dev/hideSensitiveContent";
|
||||||
|
import type { ThemeManager } from "@/plugins/built-in/themes/theme-manager";
|
||||||
|
|
||||||
const themeManager = ThemeManager.getInstance();
|
let themeManagerPromise: Promise<ThemeManager> | null = null;
|
||||||
|
|
||||||
|
function getThemeManager(): Promise<ThemeManager> {
|
||||||
|
if (!themeManagerPromise) {
|
||||||
|
themeManagerPromise = import("@/plugins/built-in/themes/theme-manager").then(
|
||||||
|
({ ThemeManager }) => ThemeManager.getInstance(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return themeManagerPromise;
|
||||||
|
}
|
||||||
|
|
||||||
export class MessageHandler {
|
export class MessageHandler {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -34,6 +39,7 @@ export class MessageHandler {
|
|||||||
case "UpdateThemePreview":
|
case "UpdateThemePreview":
|
||||||
if (request?.save == true) {
|
if (request?.save == true) {
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
|
const themeManager = await getThemeManager();
|
||||||
await themeManager.saveTheme({
|
await themeManager.saveTheme({
|
||||||
...request.body,
|
...request.body,
|
||||||
userEdited: true,
|
userEdited: true,
|
||||||
@@ -44,57 +50,78 @@ export class MessageHandler {
|
|||||||
sendResponse({ status: "success" });
|
sendResponse({ status: "success" });
|
||||||
sendThemeUpdate();
|
sendThemeUpdate();
|
||||||
};
|
};
|
||||||
save();
|
void save();
|
||||||
} else {
|
} else {
|
||||||
|
void getThemeManager().then((themeManager) => {
|
||||||
themeManager.updatePreview(request.body);
|
themeManager.updatePreview(request.body);
|
||||||
sendResponse({ status: "success" });
|
sendResponse({ status: "success" });
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
case "GetTheme":
|
case "GetTheme":
|
||||||
|
void getThemeManager().then((themeManager) => {
|
||||||
themeManager.getTheme(request.body.themeID).then((theme) => {
|
themeManager.getTheme(request.body.themeID).then((theme) => {
|
||||||
sendResponse(theme);
|
sendResponse(theme);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
case "SetTheme":
|
case "SetTheme":
|
||||||
|
void getThemeManager().then((themeManager) => {
|
||||||
themeManager.setTheme(request.body.themeID).then(() => {
|
themeManager.setTheme(request.body.themeID).then(() => {
|
||||||
sendResponse({ status: "success" });
|
sendResponse({ status: "success" });
|
||||||
});
|
});
|
||||||
|
});
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
case "DisableTheme":
|
case "DisableTheme":
|
||||||
|
void getThemeManager().then((themeManager) => {
|
||||||
themeManager.disableTheme().then(() => {
|
themeManager.disableTheme().then(() => {
|
||||||
sendResponse({ status: "success" });
|
sendResponse({ status: "success" });
|
||||||
});
|
});
|
||||||
|
});
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
case "DeleteTheme":
|
case "DeleteTheme":
|
||||||
|
void getThemeManager().then((themeManager) => {
|
||||||
themeManager.deleteTheme(request.body.themeID).then(() => {
|
themeManager.deleteTheme(request.body.themeID).then(() => {
|
||||||
sendResponse({ status: "success" });
|
sendResponse({ status: "success" });
|
||||||
});
|
});
|
||||||
|
});
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
case "ListThemes":
|
case "ListThemes":
|
||||||
|
void getThemeManager().then((themeManager) => {
|
||||||
themeManager.getAvailableThemes().then((themes) => {
|
themeManager.getAvailableThemes().then((themes) => {
|
||||||
sendResponse(themes);
|
sendResponse(themes);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
case "OpenThemeCreator":
|
case "OpenThemeCreator": {
|
||||||
const themeID = request?.body?.themeID;
|
const themeID = request?.body?.themeID;
|
||||||
OpenThemeCreator(themeID ? themeID : "");
|
void import("@/plugins/built-in/themes/ThemeCreator").then(
|
||||||
|
({ OpenThemeCreator }) => {
|
||||||
|
void OpenThemeCreator(themeID ? themeID : "");
|
||||||
|
},
|
||||||
|
);
|
||||||
closeExtensionPopup();
|
closeExtensionPopup();
|
||||||
sendResponse({ status: "success" });
|
sendResponse({ status: "success" });
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case "ShareTheme":
|
case "ShareTheme":
|
||||||
|
void getThemeManager().then((themeManager) => {
|
||||||
themeManager.shareTheme(request.body.themeID).then((id) => {
|
themeManager.shareTheme(request.body.themeID).then((id) => {
|
||||||
sendResponse({ status: "success", id });
|
sendResponse({ status: "success", id });
|
||||||
});
|
});
|
||||||
|
});
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
case "CloseThemeCreator":
|
case "CloseThemeCreator":
|
||||||
|
void import("@/plugins/built-in/themes/ThemeCreator").then(
|
||||||
|
({ CloseThemeCreator }) => {
|
||||||
try {
|
try {
|
||||||
CloseThemeCreator();
|
CloseThemeCreator();
|
||||||
sendResponse({ status: "success" });
|
sendResponse({ status: "success" });
|
||||||
@@ -102,7 +129,9 @@ export class MessageHandler {
|
|||||||
console.error("Error closing theme creator:", error);
|
console.error("Error closing theme creator:", error);
|
||||||
sendResponse({ status: "error" });
|
sendResponse({ status: "error" });
|
||||||
}
|
}
|
||||||
break;
|
},
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
|
||||||
case "HideSensitive":
|
case "HideSensitive":
|
||||||
hideSensitiveContent();
|
hideSensitiveContent();
|
||||||
|
|||||||
@@ -16,6 +16,23 @@ function isExcludedSettingsKey(key: string): boolean {
|
|||||||
return EXCLUDED_FROM_SETTINGS_SURFACE.has(key);
|
return EXCLUDED_FROM_SETTINGS_SURFACE.has(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SAVE_DEBOUNCE_MS = 200;
|
||||||
|
|
||||||
|
function storageChangeIsNoop(oldValue: unknown, newValue: unknown): boolean {
|
||||||
|
if (oldValue === newValue) return true;
|
||||||
|
if (
|
||||||
|
oldValue === undefined ||
|
||||||
|
newValue === undefined ||
|
||||||
|
typeof oldValue !== "object" ||
|
||||||
|
typeof newValue !== "object" ||
|
||||||
|
oldValue === null ||
|
||||||
|
newValue === null
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return JSON.stringify(oldValue) === JSON.stringify(newValue);
|
||||||
|
}
|
||||||
|
|
||||||
type ChangeListener = (newValue: any, oldValue: any) => void;
|
type ChangeListener = (newValue: any, oldValue: any) => void;
|
||||||
type GlobalChangeListener = (newValue: any, oldValue: any, key: string) => void;
|
type GlobalChangeListener = (newValue: any, oldValue: any, key: string) => void;
|
||||||
|
|
||||||
@@ -25,9 +42,11 @@ class StorageManager {
|
|||||||
private listeners: Map<string, Set<ChangeListener>>;
|
private listeners: Map<string, Set<ChangeListener>>;
|
||||||
private globalListeners: Set<GlobalChangeListener>;
|
private globalListeners: Set<GlobalChangeListener>;
|
||||||
private subscribers: Set<Subscriber<SettingsState>> = new Set();
|
private subscribers: Set<Subscriber<SettingsState>> = new Set();
|
||||||
private saveTimeout: NodeJS.Timeout | null = null;
|
private saveTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private pendingPatch: Record<string, unknown> = {};
|
||||||
private initialized = false;
|
private initialized = false;
|
||||||
private bootstrapping = false;
|
private bootstrapping = false;
|
||||||
|
private suppressWrites = false;
|
||||||
|
|
||||||
private constructor() {
|
private constructor() {
|
||||||
this.data = {} as SettingsState;
|
this.data = {} as SettingsState;
|
||||||
@@ -151,12 +170,14 @@ class StorageManager {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async saveToStorage(changedKeys?: string[]): Promise<void> {
|
public setSuppressWrites(suppress: boolean): void {
|
||||||
if (this.saveTimeout) {
|
this.suppressWrites = suppress;
|
||||||
clearTimeout(this.saveTimeout);
|
if (!suppress) {
|
||||||
this.saveTimeout = null;
|
this.scheduleDebouncedSave();
|
||||||
}
|
}
|
||||||
const payload: Record<string, unknown> = {};
|
}
|
||||||
|
|
||||||
|
private queueStoragePatch(changedKeys?: string[]): void {
|
||||||
const keys =
|
const keys =
|
||||||
changedKeys && changedKeys.length > 0
|
changedKeys && changedKeys.length > 0
|
||||||
? changedKeys
|
? changedKeys
|
||||||
@@ -166,18 +187,42 @@ class StorageManager {
|
|||||||
if (isExcludedSettingsKey(key)) continue;
|
if (isExcludedSettingsKey(key)) continue;
|
||||||
const value = (this.data as Record<string, unknown>)[key];
|
const value = (this.data as Record<string, unknown>)[key];
|
||||||
if (value !== undefined) {
|
if (value !== undefined) {
|
||||||
payload[key] = value;
|
this.pendingPatch[key] = value;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Object.keys(payload).length === 0) return;
|
private scheduleDebouncedSave(): void {
|
||||||
|
if (this.bootstrapping || this.suppressWrites) return;
|
||||||
|
if (Object.keys(this.pendingPatch).length === 0) return;
|
||||||
|
|
||||||
await browser.storage.local.set(payload);
|
if (this.saveTimeout) {
|
||||||
|
clearTimeout(this.saveTimeout);
|
||||||
|
}
|
||||||
|
this.saveTimeout = setTimeout(() => {
|
||||||
|
void this.flushPendingPatch();
|
||||||
|
}, SAVE_DEBOUNCE_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async flushPendingPatch(): Promise<void> {
|
||||||
|
this.saveTimeout = null;
|
||||||
|
if (this.bootstrapping || this.suppressWrites) return;
|
||||||
|
|
||||||
|
const patch = { ...this.pendingPatch };
|
||||||
|
this.pendingPatch = {};
|
||||||
|
if (Object.keys(patch).length === 0) return;
|
||||||
|
|
||||||
|
await browser.storage.local.set(patch);
|
||||||
if (!this.bootstrapping) {
|
if (!this.bootstrapping) {
|
||||||
this.notifySubscribers();
|
this.notifySubscribers();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public saveToStorage(changedKeys?: string[]): void {
|
||||||
|
this.queueStoragePatch(changedKeys);
|
||||||
|
this.scheduleDebouncedSave();
|
||||||
|
}
|
||||||
|
|
||||||
private async removeFromStorage(key: string): Promise<void> {
|
private async removeFromStorage(key: string): Promise<void> {
|
||||||
await browser.storage.local.remove(key);
|
await browser.storage.local.remove(key);
|
||||||
}
|
}
|
||||||
@@ -189,7 +234,7 @@ class StorageManager {
|
|||||||
const actualChanges: string[] = [];
|
const actualChanges: string[] = [];
|
||||||
|
|
||||||
for (const [key, { oldValue, newValue }] of Object.entries(changes)) {
|
for (const [key, { oldValue, newValue }] of Object.entries(changes)) {
|
||||||
if (JSON.stringify(oldValue) === JSON.stringify(newValue)) continue;
|
if (storageChangeIsNoop(oldValue, newValue)) continue;
|
||||||
if (isExcludedSettingsKey(key)) continue;
|
if (isExcludedSettingsKey(key)) continue;
|
||||||
|
|
||||||
if (newValue !== undefined) {
|
if (newValue !== undefined) {
|
||||||
@@ -292,3 +337,7 @@ class StorageManager {
|
|||||||
export const settingsState = StorageManager.getInstance();
|
export const settingsState = StorageManager.getInstance();
|
||||||
export const initializeSettingsState = async () =>
|
export const initializeSettingsState = async () =>
|
||||||
await StorageManager.initialize();
|
await StorageManager.initialize();
|
||||||
|
|
||||||
|
export function setSettingsStateSuppressWrites(suppress: boolean): void {
|
||||||
|
settingsState.setSuppressWrites(suppress);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,30 +1,15 @@
|
|||||||
import { OpenStorePage } from "@/seqta/ui/renderStore";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Module-level handoff for "open the theme store and highlight this theme".
|
* Module-level handoff for "open the theme store and highlight this theme".
|
||||||
*
|
|
||||||
* The store page is mounted lazily inside a Shadow DOM the first time it
|
|
||||||
* opens, so a `CustomEvent` listener would have to be wired up before mount
|
|
||||||
* (causing a race). Using a shared cell keeps the producer (popup button) and
|
|
||||||
* consumer (store `onMount`) decoupled without that timing constraint.
|
|
||||||
*
|
|
||||||
* The store reads & clears this on mount via {@link consumePendingHighlightThemeId}.
|
|
||||||
*/
|
*/
|
||||||
let pendingHighlightThemeId: string | null = null;
|
let pendingHighlightThemeId: string | null = null;
|
||||||
|
|
||||||
/** Read and clear the pending theme id (called by the store on mount). */
|
|
||||||
export function consumePendingHighlightThemeId(): string | null {
|
export function consumePendingHighlightThemeId(): string | null {
|
||||||
const id = pendingHighlightThemeId;
|
const id = pendingHighlightThemeId;
|
||||||
pendingHighlightThemeId = null;
|
pendingHighlightThemeId = null;
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export async function openThemeStoreWithHighlight(themeId: string): Promise<void> {
|
||||||
* Opens the theme store and asks it to focus / highlight the given theme.
|
|
||||||
* If the store is already mounted we dispatch a DOM event so it can react
|
|
||||||
* without remounting; otherwise the store consumes the pending id on mount.
|
|
||||||
*/
|
|
||||||
export function openThemeStoreWithHighlight(themeId: string): void {
|
|
||||||
pendingHighlightThemeId = themeId;
|
pendingHighlightThemeId = themeId;
|
||||||
|
|
||||||
const existing = document.getElementById("store");
|
const existing = document.getElementById("store");
|
||||||
@@ -35,5 +20,6 @@ export function openThemeStoreWithHighlight(themeId: string): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
OpenStorePage();
|
const { OpenStorePage } = await import("@/seqta/ui/renderStore");
|
||||||
|
await OpenStorePage();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
type SettingsPopupCallback = () => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Singleton that notifies listeners when the in-page settings popup closes.
|
||||||
|
* Used by the colour picker and other overlays tied to ExtensionPopup.
|
||||||
|
*/
|
||||||
|
class SettingsPopup {
|
||||||
|
private static instance: SettingsPopup;
|
||||||
|
private listeners: Set<SettingsPopupCallback> = new Set();
|
||||||
|
|
||||||
|
private constructor() {}
|
||||||
|
|
||||||
|
public static getInstance(): SettingsPopup {
|
||||||
|
if (!SettingsPopup.instance) {
|
||||||
|
SettingsPopup.instance = new SettingsPopup();
|
||||||
|
}
|
||||||
|
return SettingsPopup.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public addListener(callback: SettingsPopupCallback): void {
|
||||||
|
this.listeners.add(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
public removeListener(callback: SettingsPopupCallback): void {
|
||||||
|
this.listeners.delete(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
public triggerClose(): void {
|
||||||
|
this.listeners.forEach((callback) => callback());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const settingsPopup = SettingsPopup.getInstance();
|
||||||
@@ -17,7 +17,7 @@ export function setupSettingsButton() {
|
|||||||
if (SettingsClicked) {
|
if (SettingsClicked) {
|
||||||
closeExtensionPopup(extensionPopup as HTMLElement);
|
closeExtensionPopup(extensionPopup as HTMLElement);
|
||||||
} else {
|
} else {
|
||||||
renderSettingsIfNeeded();
|
await renderSettingsIfNeeded();
|
||||||
|
|
||||||
await delay(30);
|
await delay(30);
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
import { eventManager } from "@/seqta/utils/listeners/EventManager";
|
|
||||||
import { delay } from "@/seqta/utils/delay";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Asynchronously waits for an element to be present in the DOM.
|
* Asynchronously waits for an element to be present in the DOM.
|
||||||
*
|
*
|
||||||
* This function can use either a polling mechanism (via `setTimeout`) or
|
* By default uses direct `querySelector` plus a targeted `MutationObserver`
|
||||||
* a `MutationObserver` (via `eventManager.register`) to detect the element.
|
* on `document.documentElement`. Polling via `setTimeout` is available as a
|
||||||
* By default, it uses the `eventManager` which is more efficient.
|
* fallback when `usePolling` is true.
|
||||||
*
|
*
|
||||||
* @param {string} selector The CSS selector for the target element.
|
* @param {string} selector The CSS selector for the target element.
|
||||||
* @param {boolean} [usePolling=false] If true, forces the use of `setTimeout` for polling.
|
* @param {boolean} [usePolling=false] If true, forces the use of `setTimeout` for polling.
|
||||||
@@ -24,9 +21,6 @@ export async function waitForElm(
|
|||||||
if (usePolling) {
|
if (usePolling) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
let iterations = 0;
|
let iterations = 0;
|
||||||
if (maxIterations) {
|
|
||||||
iterations = 0;
|
|
||||||
}
|
|
||||||
const checkForElement = () => {
|
const checkForElement = () => {
|
||||||
const element = document.querySelector(selector);
|
const element = document.querySelector(selector);
|
||||||
if (element) {
|
if (element) {
|
||||||
@@ -36,6 +30,7 @@ export async function waitForElm(
|
|||||||
iterations++;
|
iterations++;
|
||||||
if (iterations >= maxIterations) {
|
if (iterations >= maxIterations) {
|
||||||
reject(new Error("Element not found"));
|
reject(new Error("Element not found"));
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setTimeout(checkForElement, interval);
|
setTimeout(checkForElement, interval);
|
||||||
@@ -43,47 +38,46 @@ export async function waitForElm(
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (document.readyState === "loading") {
|
if (document.readyState === "loading") {
|
||||||
document.addEventListener("DOMContentLoaded", checkForElement);
|
document.addEventListener("DOMContentLoaded", checkForElement, {
|
||||||
|
once: true,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
checkForElement();
|
checkForElement();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
}
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const registerObserver = () => {
|
const tryResolve = (): boolean => {
|
||||||
const { unregister } = eventManager.register(
|
const element = document.querySelector(selector);
|
||||||
`${selector}`,
|
if (element) {
|
||||||
{
|
|
||||||
customCheck: (element) => element.matches(selector),
|
|
||||||
},
|
|
||||||
async (element) => {
|
|
||||||
resolve(element);
|
resolve(element);
|
||||||
await delay(1);
|
return true;
|
||||||
unregister(); // Remove the listener once the element is found
|
}
|
||||||
},
|
return false;
|
||||||
);
|
|
||||||
return unregister;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let unregister = null;
|
const startObserver = () => {
|
||||||
|
if (tryResolve()) return;
|
||||||
|
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
if (tryResolve()) {
|
||||||
|
observer.disconnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(document.documentElement, {
|
||||||
|
childList: true,
|
||||||
|
subtree: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
if (document.readyState === "loading") {
|
if (document.readyState === "loading") {
|
||||||
// DOM is still loading, wait for it to be ready
|
document.addEventListener("DOMContentLoaded", startObserver, {
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
once: true,
|
||||||
unregister = registerObserver();
|
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
unregister = registerObserver();
|
startObserver();
|
||||||
}
|
|
||||||
|
|
||||||
const querySelector = () => document.querySelector(selector);
|
|
||||||
const element = querySelector();
|
|
||||||
|
|
||||||
if (element) {
|
|
||||||
if (unregister) unregister();
|
|
||||||
resolve(element);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user