mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-08-28 09:11:06 +00:00
feat: final bug fixes
This commit is contained in:
+28
-9
@@ -481,7 +481,7 @@ ul.magicDelete > li.deleting {
|
||||
place-items: center;
|
||||
}
|
||||
#menu .sub {
|
||||
top: 71.5px;
|
||||
top: 71px;
|
||||
margin-top: -2px;
|
||||
}
|
||||
|
||||
@@ -728,7 +728,7 @@ html.bsplus-custom-title-pending #title > :not(#bsplus-title-root),
|
||||
&::before {
|
||||
box-shadow: none !important;
|
||||
border-bottom: none !important;
|
||||
height: calc(69.5px + 8px) !important;
|
||||
height: calc(71px + 8px) !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1123,7 +1123,7 @@ ol:has([class*="MessageList__avatar___"] svg) {
|
||||
}
|
||||
#container #content::before {
|
||||
content: "";
|
||||
height: 69.5px;
|
||||
height: 71px;
|
||||
width: 100%;
|
||||
}
|
||||
.menuShown #container #content #title {
|
||||
@@ -1356,7 +1356,7 @@ body.student #menu > ul::before {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background-color: transparent;
|
||||
height: 69.5px;
|
||||
height: 71px;
|
||||
box-shadow: 0px 0px 4px 2px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
@@ -1605,9 +1605,14 @@ html.transparencyEffects
|
||||
color: var(--text-primary) !important;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
padding-right: 56px !important;
|
||||
height: 54px;
|
||||
min-height: 48px;
|
||||
/* Must match logo/`#content` spacer (~71px). A 54px height only "looked"
|
||||
taller in DEV (content-box); production content-script Tailwind forces
|
||||
border-box and collapses the bar to 54px. */
|
||||
height: 71px;
|
||||
min-height: 71px;
|
||||
box-shadow: rgba(0, 0, 0, 0.35) 0px 0px 10px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
font-weight: 500 !important;
|
||||
@@ -1620,12 +1625,13 @@ html.transparencyEffects
|
||||
|
||||
#title::before {
|
||||
background-image: var(--betterseqta-logo);
|
||||
background-position: left;
|
||||
background-position: left center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: auto 48px;
|
||||
height: 69.5px;
|
||||
content: "";
|
||||
width: 184px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.rightElements {
|
||||
display: flex;
|
||||
@@ -1648,10 +1654,20 @@ html.transparencyEffects
|
||||
color: var(--text-color);
|
||||
}
|
||||
.timetable-arrows {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
gap: 4px;
|
||||
padding: 5px;
|
||||
}
|
||||
.timetable-arrows svg {
|
||||
margin: 0 5px;
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin: 0;
|
||||
position: static;
|
||||
cursor: pointer;
|
||||
}
|
||||
.timetable-arrows svg:hover {
|
||||
@@ -1801,9 +1817,12 @@ html.transparencyEffects
|
||||
-webkit-box-shadow: 0px 5px 16px 6px rgba(0, 0, 0, 0.3);
|
||||
box-shadow: 0px 5px 16px 6px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
.border {
|
||||
/* Not named `.border` — Tailwind’s utility of that name is injected into the
|
||||
page via content-script CSS in production and would paint a 1px outline. */
|
||||
.bsplus-rounded {
|
||||
border-radius: 16px;
|
||||
overflow: clip;
|
||||
border-width: 0;
|
||||
}
|
||||
.shortcut-container h2 {
|
||||
font-size: 20px;
|
||||
|
||||
@@ -20,6 +20,37 @@
|
||||
animation: none !important;
|
||||
}
|
||||
|
||||
/*
|
||||
* Custom sidebar: park sibling chrome while `.drilling` is set.
|
||||
* SEQTA strips/restores `.active` on every nested leaf click; keying only off
|
||||
* `:has(.active)` briefly undoes the translate and the 0.3s transform
|
||||
* transition makes that look like the open animation flashing again.
|
||||
*
|
||||
* Scope carefully — only root siblings (and nested-folder siblings via
|
||||
* `.sub:has(.sub)`), never the visible leaf rows in the current panel.
|
||||
*/
|
||||
#menu.bsplus-custom-sidebar #bsplus-sidebar-root.drilling > li::before,
|
||||
#menu.bsplus-custom-sidebar #bsplus-sidebar-root.drilling > li::after,
|
||||
#menu.bsplus-custom-sidebar #bsplus-sidebar-root.drilling > li > label,
|
||||
#menu.bsplus-custom-sidebar #bsplus-sidebar-root.drilling > li > svg {
|
||||
transform: translateX(-320px) !important;
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
#menu.bsplus-custom-sidebar #bsplus-sidebar-root.drilling .sub:has(.sub) > ul > li::before,
|
||||
#menu.bsplus-custom-sidebar #bsplus-sidebar-root.drilling .sub:has(.sub) > ul > li::after,
|
||||
#menu.bsplus-custom-sidebar #bsplus-sidebar-root.drilling .sub:has(.sub) > ul > li > label,
|
||||
#menu.bsplus-custom-sidebar #bsplus-sidebar-root.drilling .sub:has(.sub) > ul > li > svg {
|
||||
transform: translateX(-320px) !important;
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
#menu.bsplus-custom-sidebar #bsplus-sidebar-root.drilling {
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
#menu li.hasChildren:not(.active) li .sub {
|
||||
display: none; // improves performance
|
||||
}
|
||||
@@ -38,6 +69,13 @@
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
#menu.bsplus-custom-sidebar #bsplus-sidebar-root.drilling > li > label,
|
||||
#menu.bsplus-custom-sidebar #bsplus-sidebar-root.drilling > li > svg,
|
||||
#menu.bsplus-custom-sidebar #bsplus-sidebar-root.drilling .sub:has(.sub) > ul > li > label,
|
||||
#menu.bsplus-custom-sidebar #bsplus-sidebar-root.drilling .sub:has(.sub) > ul > li > svg {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
#menu .sub {
|
||||
transition: transform 0.3s ease, left 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
@@ -55,3 +93,13 @@ body.icon-only-sidebar:not(:has(#menu li.hasChildren.active)) #menu > ul ul:has(
|
||||
body.icon-only-sidebar:not(:has(#menu li.hasChildren.active)) #menu > ul ul:has(li.hasChildren.active) > li > svg {
|
||||
transform: translateX(-70px);
|
||||
}
|
||||
|
||||
body.icon-only-sidebar #menu.bsplus-custom-sidebar #bsplus-sidebar-root.drilling > li::before,
|
||||
body.icon-only-sidebar #menu.bsplus-custom-sidebar #bsplus-sidebar-root.drilling > li > label,
|
||||
body.icon-only-sidebar #menu.bsplus-custom-sidebar #bsplus-sidebar-root.drilling > li > svg,
|
||||
body.icon-only-sidebar #menu.bsplus-custom-sidebar #bsplus-sidebar-root.drilling .sub:has(.sub) > ul > li::before,
|
||||
body.icon-only-sidebar #menu.bsplus-custom-sidebar #bsplus-sidebar-root.drilling .sub:has(.sub) > ul > li > label,
|
||||
body.icon-only-sidebar #menu.bsplus-custom-sidebar #bsplus-sidebar-root.drilling .sub:has(.sub) > ul > li > svg {
|
||||
transform: translateX(-70px) !important;
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ html.transparencyEffects {
|
||||
/* Blurs */
|
||||
.search,
|
||||
.document,
|
||||
.border,
|
||||
.bsplus-rounded,
|
||||
.draggable,
|
||||
.notice,
|
||||
[class*="BasicPanel__BasicPanel___"],
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { PluginAPI } from "@/plugins/core/types";
|
||||
import { waitForElm } from "@/seqta/utils/waitForElm";
|
||||
import { waitForSeqtaMenu } from "@/seqta/utils/waitForSeqtaShell";
|
||||
|
||||
export const ANIMATED_BG_MARKER = "bsplus-animated-bg";
|
||||
|
||||
@@ -51,7 +52,7 @@ export async function syncAnimatedBackground(
|
||||
try {
|
||||
const [container, menu] = await Promise.all([
|
||||
waitForElm("#container", true),
|
||||
waitForElm("#menu", true),
|
||||
waitForSeqtaMenu(),
|
||||
]);
|
||||
ensureAnimatedBackgroundLayers(
|
||||
container as HTMLElement,
|
||||
|
||||
@@ -14,11 +14,15 @@
|
||||
let outputUnit = $state<string>('');
|
||||
let isPartial = $state(false);
|
||||
|
||||
const processInput = (input: string) => {
|
||||
let calcGen = 0;
|
||||
|
||||
const processInput = async (input: string) => {
|
||||
const gen = ++calcGen;
|
||||
isCalculating = true;
|
||||
|
||||
try {
|
||||
const calcResult = calculateExpression(input);
|
||||
const calcResult = await calculateExpression(input);
|
||||
if (gen !== calcGen) return;
|
||||
|
||||
if (calcResult.isValid) {
|
||||
result = calcResult.result;
|
||||
@@ -33,19 +37,20 @@
|
||||
isPartial = false;
|
||||
dispatch('hasResult', null);
|
||||
}
|
||||
} catch (e) {
|
||||
} catch {
|
||||
if (gen !== calcGen) return;
|
||||
result = null;
|
||||
inputUnit = '';
|
||||
outputUnit = '';
|
||||
isPartial = false;
|
||||
dispatch('hasResult', null);
|
||||
} finally {
|
||||
isCalculating = false;
|
||||
}
|
||||
if (gen === calcGen) isCalculating = false;
|
||||
}
|
||||
};
|
||||
|
||||
$effect(() => {
|
||||
processInput(searchTerm);
|
||||
void processInput(searchTerm);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
|
||||
@@ -114,7 +114,7 @@
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
const itemsUpdatedHandler = (event: Event) => {
|
||||
const applyItemsUpdate = (event: Event) => {
|
||||
const detail = (event as CustomEvent<DynamicItemsUpdatedDetail>).detail;
|
||||
|
||||
if (
|
||||
@@ -142,6 +142,21 @@
|
||||
setupSearchIndexes();
|
||||
performSearch();
|
||||
};
|
||||
|
||||
// Trace showed ~18ms itemsUpdatedHandler spikes; coalesce bursts via rAF.
|
||||
let itemsUpdateRaf = 0;
|
||||
let pendingItemsEvent: Event | null = null;
|
||||
const itemsUpdatedHandler = (event: Event) => {
|
||||
pendingItemsEvent = event;
|
||||
if (itemsUpdateRaf) return;
|
||||
itemsUpdateRaf = requestAnimationFrame(() => {
|
||||
itemsUpdateRaf = 0;
|
||||
const next = pendingItemsEvent;
|
||||
pendingItemsEvent = null;
|
||||
if (next) applyItemsUpdate(next);
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener('dynamic-items-updated', itemsUpdatedHandler);
|
||||
|
||||
setupSearchIndexes();
|
||||
@@ -153,6 +168,7 @@
|
||||
};
|
||||
|
||||
return () => {
|
||||
if (itemsUpdateRaf) cancelAnimationFrame(itemsUpdateRaf);
|
||||
window.removeEventListener('dynamic-items-updated', itemsUpdatedHandler);
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Plugin } from "@/plugins/core/types";
|
||||
import { verboseDebug, verboseLog } from "@/utils/verboseLog";
|
||||
import styles from "./styles.css?inline";
|
||||
import { waitForElm } from "@/seqta/utils/waitForElm";
|
||||
import { waitForSeqtaTitle } from "@/seqta/utils/waitForSeqtaShell";
|
||||
import { runIndexing, ensureSchemaCurrent } from "../indexing/indexer";
|
||||
import { installResetIndexMessageListener } from "../indexing/resetIndexes";
|
||||
import { isIndexingPaused } from "../indexing/indexingPause";
|
||||
@@ -13,6 +13,14 @@ import {
|
||||
installPassiveObserver,
|
||||
} from "../indexing/passiveObserver";
|
||||
|
||||
function scheduleIdleIndexing(run: () => void): void {
|
||||
if (typeof requestIdleCallback === "function") {
|
||||
requestIdleCallback(() => run(), { timeout: 5000 });
|
||||
return;
|
||||
}
|
||||
setTimeout(run, 2000);
|
||||
}
|
||||
|
||||
const globalSearchPlugin: Plugin<{}> = {
|
||||
id: "global-search",
|
||||
name: "Global Search",
|
||||
@@ -84,17 +92,12 @@ const globalSearchPlugin: Plugin<{}> = {
|
||||
}
|
||||
|
||||
if (api.settings.runIndexingOnLoad && !isIndexingPaused()) {
|
||||
setTimeout(async () => {
|
||||
if (!isIndexingPaused()) await runIndexing();
|
||||
}, 2000);
|
||||
scheduleIdleIndexing(() => {
|
||||
if (!isIndexingPaused()) void runIndexing();
|
||||
});
|
||||
}
|
||||
|
||||
const title = document.querySelector("#title");
|
||||
if (title) {
|
||||
void mountSearchBar(title, api, appRef);
|
||||
} else {
|
||||
void mountSearchBar(await waitForElm("#title", true, 100, 60), api, appRef);
|
||||
}
|
||||
void mountSearchBar(await waitForSeqtaTitle(100, 60), api, appRef);
|
||||
|
||||
return () => cleanupSearchBar(appRef);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import {
|
||||
calculateExpression,
|
||||
isLikelyMathExpression,
|
||||
} from "./calculator";
|
||||
|
||||
describe("calculator", () => {
|
||||
it("rejects non-math text without needing mathjs for validity", () => {
|
||||
expect(isLikelyMathExpression("homework due tomorrow")).toBe(false);
|
||||
expect(isLikelyMathExpression("2 + 2")).toBe(true);
|
||||
});
|
||||
|
||||
it("evaluates expressions after dynamic mathjs load", async () => {
|
||||
const result = await calculateExpression("2 + 2");
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.result).toBe("4");
|
||||
});
|
||||
|
||||
it("returns empty for plain search queries", async () => {
|
||||
const result = await calculateExpression("assessments");
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.result).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,3 @@
|
||||
import {
|
||||
create, absDependencies, addDependencies, cosDependencies, divideDependencies, eDependencies,
|
||||
evaluateDependencies, formatDependencies, log10Dependencies, logDependencies, modDependencies,
|
||||
multiplyDependencies, piDependencies, powDependencies, sinDependencies, sqrtDependencies,
|
||||
subtractDependencies, tanDependencies, toDependencies, typeOfDependencies, unaryMinusDependencies,
|
||||
unaryPlusDependencies, unitDependencies,
|
||||
} from 'mathjs';
|
||||
import { unitFullNames } from './unitMap';
|
||||
|
||||
export interface CalculatorResult {
|
||||
@@ -18,45 +11,106 @@ export interface CalculatorResult {
|
||||
|
||||
export const CALCULATOR_MAX_INPUT_LENGTH = 128;
|
||||
|
||||
const CALCULATOR_MATH_CONFIG = Object.assign(
|
||||
{},
|
||||
evaluateDependencies, formatDependencies, unitDependencies, typeOfDependencies,
|
||||
addDependencies, subtractDependencies, multiplyDependencies, divideDependencies,
|
||||
powDependencies, modDependencies, unaryMinusDependencies, unaryPlusDependencies,
|
||||
absDependencies, sqrtDependencies, logDependencies, log10Dependencies,
|
||||
sinDependencies, cosDependencies, tanDependencies, toDependencies,
|
||||
piDependencies, eDependencies,
|
||||
);
|
||||
type CalculatorMath = {
|
||||
evaluate: (expr: string) => unknown;
|
||||
format: (value: unknown, opts: object) => string;
|
||||
typeOf: (value: unknown) => string;
|
||||
unit: (expression: string) => { formatUnits: () => string } | null;
|
||||
import: (fns: Record<string, () => never>, opts: { override: boolean }) => void;
|
||||
};
|
||||
|
||||
const BLOCKED_MATH_FUNCTIONS = ['import', 'createUnit', 'random', 'pickRandom', 'chain', 'help'] as const;
|
||||
|
||||
function createSandboxedMath() {
|
||||
const sandbox = create(CALCULATOR_MATH_CONFIG);
|
||||
const blockFn = () => { throw new Error('Function not allowed'); };
|
||||
const blocked: Record<string, () => never> = {};
|
||||
for (const name of BLOCKED_MATH_FUNCTIONS) blocked[name] = blockFn;
|
||||
sandbox.import(blocked, { override: true });
|
||||
return sandbox;
|
||||
}
|
||||
|
||||
const calculatorMath = createSandboxedMath();
|
||||
const FORMAT_OPTS = { precision: 14, lowerExp: -15, upperExp: 15 } as const;
|
||||
const emptyResult = (error?: string): CalculatorResult => ({
|
||||
result: null, isValid: false, isPartial: false, inputUnit: '', outputUnit: '', error,
|
||||
});
|
||||
|
||||
function detectUnit(expression: string): string {
|
||||
const BLOCKED_MATH_FUNCTIONS = ['import', 'createUnit', 'random', 'pickRandom', 'chain', 'help'] as const;
|
||||
|
||||
let calculatorMathPromise: Promise<CalculatorMath> | null = null;
|
||||
|
||||
async function getCalculatorMath(): Promise<CalculatorMath> {
|
||||
if (!calculatorMathPromise) {
|
||||
calculatorMathPromise = (async () => {
|
||||
const {
|
||||
create,
|
||||
absDependencies,
|
||||
addDependencies,
|
||||
cosDependencies,
|
||||
divideDependencies,
|
||||
eDependencies,
|
||||
evaluateDependencies,
|
||||
formatDependencies,
|
||||
log10Dependencies,
|
||||
logDependencies,
|
||||
modDependencies,
|
||||
multiplyDependencies,
|
||||
piDependencies,
|
||||
powDependencies,
|
||||
sinDependencies,
|
||||
sqrtDependencies,
|
||||
subtractDependencies,
|
||||
tanDependencies,
|
||||
toDependencies,
|
||||
typeOfDependencies,
|
||||
unaryMinusDependencies,
|
||||
unaryPlusDependencies,
|
||||
unitDependencies,
|
||||
} = await import('mathjs');
|
||||
|
||||
const config = Object.assign(
|
||||
{},
|
||||
evaluateDependencies,
|
||||
formatDependencies,
|
||||
unitDependencies,
|
||||
typeOfDependencies,
|
||||
addDependencies,
|
||||
subtractDependencies,
|
||||
multiplyDependencies,
|
||||
divideDependencies,
|
||||
powDependencies,
|
||||
modDependencies,
|
||||
unaryMinusDependencies,
|
||||
unaryPlusDependencies,
|
||||
absDependencies,
|
||||
sqrtDependencies,
|
||||
logDependencies,
|
||||
log10Dependencies,
|
||||
sinDependencies,
|
||||
cosDependencies,
|
||||
tanDependencies,
|
||||
toDependencies,
|
||||
piDependencies,
|
||||
eDependencies,
|
||||
);
|
||||
|
||||
const sandbox = create(config) as CalculatorMath;
|
||||
const blockFn = () => {
|
||||
throw new Error('Function not allowed');
|
||||
};
|
||||
const blocked: Record<string, () => never> = {};
|
||||
for (const name of BLOCKED_MATH_FUNCTIONS) blocked[name] = blockFn;
|
||||
sandbox.import(blocked, { override: true });
|
||||
return sandbox;
|
||||
})();
|
||||
}
|
||||
return calculatorMathPromise;
|
||||
}
|
||||
|
||||
function detectUnit(math: CalculatorMath, expression: string): string {
|
||||
try {
|
||||
const unit = calculatorMath.unit(expression);
|
||||
const unit = math.unit(expression);
|
||||
if (unit) {
|
||||
const unitStr = unit.formatUnits();
|
||||
return unitFullNames[unitStr] || unitStr;
|
||||
}
|
||||
} catch {}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function isLikelyMathExpression(input: string): boolean {
|
||||
/** Cheap pre-check so SearchBar/Calculator never load mathjs for plain text. */
|
||||
export function isLikelyMathExpression(input: string): boolean {
|
||||
const trimmed = input.trim();
|
||||
if (!/[\d+\-*/^()=.]/.test(trimmed)) return false;
|
||||
const nonMathWords = ['abs', 'function', 'class', 'const', 'let', 'var', 'if', 'else', 'while', 'for', 'return', 'import', 'export'];
|
||||
@@ -65,23 +119,25 @@ function isLikelyMathExpression(input: string): boolean {
|
||||
return /(\d+\.?\d*|\+|\-|\*|\/|\^|\(|\)|sin|cos|tan|log|sqrt|pi|e|=)/i.test(trimmed);
|
||||
}
|
||||
|
||||
function tryCompleteExpression(expression: string): string | null {
|
||||
function tryCompleteExpression(math: CalculatorMath, expression: string): string | null {
|
||||
const trimmed = expression.trim();
|
||||
for (const pattern of [/[\+\-\*\/\^]\s*$/, /\(\s*$/, /[\+\-\*\/\^]\s*\(/]) {
|
||||
if (!pattern.test(trimmed)) continue;
|
||||
const partial = trimmed.replace(/[\+\-\*\/\^]\s*$/, '').trim();
|
||||
if (!partial || partial.match(/[\+\-\*\/\^]\s*$/)) continue;
|
||||
try {
|
||||
const result = calculatorMath.evaluate(partial);
|
||||
const result = math.evaluate(partial);
|
||||
if (typeof result === 'number' && !isNaN(result)) {
|
||||
return calculatorMath.format(result, FORMAT_OPTS);
|
||||
return math.format(result, FORMAT_OPTS);
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function calculateExpression(input: string): CalculatorResult {
|
||||
export async function calculateExpression(input: string): Promise<CalculatorResult> {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed || (trimmed.length <= 2 && !/\d/.test(trimmed))) return emptyResult();
|
||||
if (trimmed.length > CALCULATOR_MAX_INPUT_LENGTH) {
|
||||
@@ -89,21 +145,23 @@ export function calculateExpression(input: string): CalculatorResult {
|
||||
}
|
||||
if (!isLikelyMathExpression(trimmed)) return emptyResult();
|
||||
|
||||
const math = await getCalculatorMath();
|
||||
|
||||
try {
|
||||
const evaluated = calculatorMath.evaluate(trimmed.replace('**', '^'));
|
||||
const evaluated = math.evaluate(trimmed.replace('**', '^'));
|
||||
if (evaluated !== undefined) {
|
||||
const result = calculatorMath.format(evaluated, FORMAT_OPTS);
|
||||
const isUnit = calculatorMath.typeOf(evaluated) === 'Unit';
|
||||
const result = math.format(evaluated, FORMAT_OPTS);
|
||||
const isUnit = math.typeOf(evaluated) === 'Unit';
|
||||
return {
|
||||
result,
|
||||
isValid: true,
|
||||
isPartial: false,
|
||||
inputUnit: isUnit ? detectUnit(trimmed) : '',
|
||||
outputUnit: isUnit ? detectUnit(result) : '',
|
||||
inputUnit: isUnit ? detectUnit(math, trimmed) : '',
|
||||
outputUnit: isUnit ? detectUnit(math, result) : '',
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
const partialResult = tryCompleteExpression(trimmed);
|
||||
const partialResult = tryCompleteExpression(math, trimmed);
|
||||
if (partialResult) {
|
||||
return { result: partialResult, isValid: true, isPartial: true, inputUnit: '', outputUnit: '' };
|
||||
}
|
||||
|
||||
+17
-2
@@ -1,6 +1,5 @@
|
||||
// Third-party libraries
|
||||
import browser from "webextension-polyfill";
|
||||
import { animate, stagger } from "motion";
|
||||
|
||||
// Internal utilities and functions
|
||||
import { GetThresholdOfColor } from "@/seqta/ui/colors/getThresholdColour";
|
||||
@@ -48,6 +47,11 @@ import iframeCSS from "@/css/iframe.scss?raw";
|
||||
|
||||
import { applyMenuItemVisibility } from "@/seqta/utils/menuItemVisibility";
|
||||
|
||||
/** Lazy-load Motion so first-paint shell (loading overlay / sidebar) stays free of it. */
|
||||
function loadMotion() {
|
||||
return import("motion");
|
||||
}
|
||||
|
||||
export function hideSideBar() {
|
||||
const sidebar = document.getElementById("menu"); // The sidebar element to be closed
|
||||
const main = document.getElementById("main"); // The main content element that must be resized to fill the page
|
||||
@@ -250,6 +254,7 @@ async function handleNotices(node: Element): Promise<void> {
|
||||
// get index of node in relation to parent
|
||||
const index = Array.from(node.parentElement!.children).indexOf(node);
|
||||
|
||||
const { animate } = await loadMotion();
|
||||
animate(
|
||||
node,
|
||||
{ opacity: [0, 1], y: [50, 0], scale: [0.99, 1] },
|
||||
@@ -371,6 +376,7 @@ async function handleMessages(node: Element): Promise<void> {
|
||||
const messages = Array.from(
|
||||
document.querySelectorAll("[data-message]"),
|
||||
).slice(0, 35);
|
||||
const { animate, stagger } = await loadMotion();
|
||||
animate(
|
||||
messages,
|
||||
{ opacity: [0, 1], y: [10, 0] },
|
||||
@@ -397,6 +403,7 @@ async function handleDashboard(node: Element): Promise<void> {
|
||||
try {
|
||||
const children = document.querySelectorAll(".dashboard > *");
|
||||
if (children.length) {
|
||||
const { animate, stagger } = await loadMotion();
|
||||
animate(
|
||||
children,
|
||||
{ opacity: [0, 1], y: [10, 0] },
|
||||
@@ -422,6 +429,7 @@ async function handleDocuments(node: Element): Promise<void> {
|
||||
try {
|
||||
const rows = document.querySelectorAll(".documents tbody tr.document");
|
||||
if (rows.length) {
|
||||
const { animate, stagger } = await loadMotion();
|
||||
animate(
|
||||
rows,
|
||||
{ opacity: [0, 1], y: [10, 0] },
|
||||
@@ -445,6 +453,7 @@ async function handleReports(node: Element): Promise<void> {
|
||||
try {
|
||||
const items = document.querySelectorAll(".reports .item");
|
||||
if (items.length) {
|
||||
const { animate, stagger } = await loadMotion();
|
||||
animate(
|
||||
items,
|
||||
{ opacity: [0, 1], y: [10, 0] },
|
||||
@@ -647,8 +656,14 @@ export function showConflictPopup() {
|
||||
|
||||
document.getElementById("container")?.append(background);
|
||||
|
||||
// CSS fade — avoid Motion on conflict overlay (not critical-path shell, but keeps Motion off this import path).
|
||||
if (settingsState.animations) {
|
||||
animate([background as HTMLElement], { opacity: [0, 1] });
|
||||
const el = background as HTMLElement;
|
||||
el.style.opacity = "0";
|
||||
el.style.transition = "opacity 200ms ease-in-out";
|
||||
requestAnimationFrame(() => {
|
||||
el.style.opacity = "1";
|
||||
});
|
||||
}
|
||||
|
||||
background.addEventListener("click", (event) => {
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
tabindex={editMode ? -1 : 0}
|
||||
aria-label={item.label}
|
||||
aria-current={active ? "page" : undefined}
|
||||
in:fly={{ x: drillEnter ? 24 : 0, duration: drillEnter ? 180 : 0 }}
|
||||
in:fly={drillEnter ? { x: 24, duration: 180 } : undefined}
|
||||
onclick={(e) => {
|
||||
// Keep SEQTA's #menu handlers from seeing custom-list clicks — that fights
|
||||
// our drill UI and can freeze the tab (Goals / Folios / etc.).
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { SettingsState } from "@/types/storage";
|
||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
|
||||
import { waitForElm } from "@/seqta/utils/waitForElm";
|
||||
import { waitForSeqtaMenu } from "@/seqta/utils/waitForSeqtaShell";
|
||||
import Sidebar from "./Sidebar.svelte";
|
||||
import { getNativeMenuList } from "./parseNativeMenu";
|
||||
import {
|
||||
@@ -28,30 +29,62 @@ let syncTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let hashListenerAttached = false;
|
||||
let sidebarCaptureAttached = false;
|
||||
let earlyPrepareStarted = false;
|
||||
let catchupTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let catchupTimers: ReturnType<typeof setTimeout>[] = [];
|
||||
let nativeMenuListenerAttached = false;
|
||||
let lastMenuFingerprint = "";
|
||||
|
||||
const settingsListeners: Array<{
|
||||
key: keyof SettingsState;
|
||||
listener: ChangeListener;
|
||||
}> = [];
|
||||
|
||||
function syncFromMenu() {
|
||||
if (menuEl) sidebarState.syncFromNative(menuEl);
|
||||
/** Stable fingerprint of native menu keys so unchanged DOM does not re-sync. */
|
||||
function menuFingerprint(menu: HTMLElement): string {
|
||||
const list = getNativeMenuList(menu);
|
||||
if (!list) return "";
|
||||
const keys: string[] = [];
|
||||
for (const node of list.children) {
|
||||
const el = node as HTMLElement;
|
||||
if (el.id === ROOT_ID) continue;
|
||||
const key = el.dataset.key ?? "";
|
||||
const path = el.dataset.path ?? "";
|
||||
const colour = el.dataset.colour ?? "";
|
||||
keys.push(`${key}|${path}|${colour}`);
|
||||
}
|
||||
return `${keys.length}:${keys.join(",")}`;
|
||||
}
|
||||
|
||||
function startCatchupSync() {
|
||||
if (catchupTimer) clearInterval(catchupTimer);
|
||||
let attempts = 0;
|
||||
catchupTimer = setInterval(() => {
|
||||
attempts += 1;
|
||||
syncFromMenu();
|
||||
// Plugins (Analytics, Overview, icons) inject shortly after first paint.
|
||||
if (attempts >= 60) {
|
||||
clearInterval(catchupTimer!);
|
||||
catchupTimer = null;
|
||||
function syncFromMenu(force = false) {
|
||||
if (!menuEl) return;
|
||||
const next = menuFingerprint(menuEl);
|
||||
if (!force && next === lastMenuFingerprint) return;
|
||||
lastMenuFingerprint = next;
|
||||
sidebarState.syncFromNative(menuEl);
|
||||
}
|
||||
}, 50);
|
||||
|
||||
/**
|
||||
* Sparse catch-up after mount: plugins inject menu items shortly after first paint.
|
||||
* MutationObserver + scheduleSync cover ongoing changes; this only covers a short window.
|
||||
*/
|
||||
function startCatchupSync() {
|
||||
for (const t of catchupTimers) clearTimeout(t);
|
||||
catchupTimers = [];
|
||||
// Immediate, then a few delayed passes (~1s total) instead of 50ms×60.
|
||||
const delays = [0, 200, 500, 1000];
|
||||
for (const ms of delays) {
|
||||
catchupTimers.push(
|
||||
setTimeout(() => {
|
||||
syncFromMenu();
|
||||
}, ms),
|
||||
);
|
||||
}
|
||||
if (typeof requestIdleCallback === "function") {
|
||||
requestIdleCallback(() => syncFromMenu(), { timeout: 1500 });
|
||||
}
|
||||
}
|
||||
|
||||
function onNativeMenuUpdated() {
|
||||
syncFromMenu(true);
|
||||
}
|
||||
|
||||
function scheduleSync() {
|
||||
@@ -201,7 +234,12 @@ export async function mountCustomSidebar(): Promise<boolean> {
|
||||
|
||||
document.documentElement.classList.add(PENDING_CLASS);
|
||||
|
||||
const menu = (await waitForElm("#menu", true, 50, 200)) as HTMLElement | null;
|
||||
let menu: HTMLElement | null = null;
|
||||
try {
|
||||
menu = (await waitForSeqtaMenu(50, 200)) as HTMLElement;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (!menu) return false;
|
||||
|
||||
// Prefer a populated native list, but don't block forever during loading.
|
||||
@@ -270,7 +308,7 @@ export async function mountCustomSidebar(): Promise<boolean> {
|
||||
}
|
||||
|
||||
if (!nativeMenuListenerAttached) {
|
||||
window.addEventListener("bsplus-native-menu-updated", syncFromMenu);
|
||||
window.addEventListener("bsplus-native-menu-updated", onNativeMenuUpdated);
|
||||
nativeMenuListenerAttached = true;
|
||||
}
|
||||
|
||||
@@ -288,7 +326,7 @@ export async function mountCustomSidebar(): Promise<boolean> {
|
||||
] as const) {
|
||||
registerSetting(key, () => applySidebarLook(menuEl));
|
||||
}
|
||||
const resync = () => syncFromMenu();
|
||||
const resync = () => syncFromMenu(true);
|
||||
registerSetting("menuorder", resync);
|
||||
registerSetting("menuitems", resync);
|
||||
|
||||
@@ -302,8 +340,9 @@ export function unmountCustomSidebar() {
|
||||
menuObserver = null;
|
||||
if (syncTimer) clearTimeout(syncTimer);
|
||||
syncTimer = null;
|
||||
if (catchupTimer) clearInterval(catchupTimer);
|
||||
catchupTimer = null;
|
||||
for (const t of catchupTimers) clearTimeout(t);
|
||||
catchupTimers = [];
|
||||
lastMenuFingerprint = "";
|
||||
|
||||
clearSettingListeners();
|
||||
|
||||
@@ -318,7 +357,7 @@ export function unmountCustomSidebar() {
|
||||
}
|
||||
|
||||
if (nativeMenuListenerAttached) {
|
||||
window.removeEventListener("bsplus-native-menu-updated", syncFromMenu);
|
||||
window.removeEventListener("bsplus-native-menu-updated", onNativeMenuUpdated);
|
||||
nativeMenuListenerAttached = false;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { mount, unmount } from "svelte";
|
||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
|
||||
import { waitForElm } from "@/seqta/utils/waitForElm";
|
||||
import { waitForSeqtaTitle } from "@/seqta/utils/waitForSeqtaShell";
|
||||
import TitleBar from "./TitleBar.svelte";
|
||||
import { titleBarState } from "./titleBarState.svelte";
|
||||
|
||||
@@ -105,7 +105,7 @@ export async function mountCustomTitleBar(): Promise<boolean> {
|
||||
|
||||
let title: HTMLElement;
|
||||
try {
|
||||
title = (await waitForElm("#title", true, 50, 200)) as HTMLElement;
|
||||
title = (await waitForSeqtaTitle(50, 200)) as HTMLElement;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -311,10 +311,10 @@ export async function loadEngageHomePage(): Promise<void> {
|
||||
const engageHomeBody = stringToHTML(/* html */ `
|
||||
<div class="home-root" id="engage-home-root">
|
||||
<div class="home-container" id="engage-home-container">
|
||||
<div class="border shortcut-container">
|
||||
<div class="border shortcuts" id="shortcuts"></div>
|
||||
<div class="bsplus-rounded shortcut-container">
|
||||
<div class="bsplus-rounded shortcuts" id="shortcuts"></div>
|
||||
</div>
|
||||
<div class="border timetable-container">
|
||||
<div class="bsplus-rounded timetable-container">
|
||||
<div class="home-subtitle">
|
||||
<div class="engage-timetable-title-cluster">
|
||||
<h2 id="engage-home-lesson-subtitle">Today's Lessons</h2>
|
||||
@@ -331,7 +331,7 @@ export async function loadEngageHomePage(): Promise<void> {
|
||||
</div>
|
||||
<div class="day-container loading" id="engage-day-container"></div>
|
||||
</div>
|
||||
<div class="border notices-container">
|
||||
<div class="bsplus-rounded notices-container">
|
||||
<div style="display: flex; justify-content: space-between">
|
||||
<h2 class="home-subtitle">Notices</h2>
|
||||
<input type="date" id="engage-notices-date" />
|
||||
|
||||
@@ -62,10 +62,10 @@ export async function loadHomePage() {
|
||||
|
||||
const skeletonStructure = stringToHTML(/* html */ `
|
||||
<div class="home-container" id="home-container">
|
||||
<div class="border shortcut-container">
|
||||
<div class="border shortcuts" id="shortcuts"></div>
|
||||
<div class="bsplus-rounded shortcut-container">
|
||||
<div class="bsplus-rounded shortcuts" id="shortcuts"></div>
|
||||
</div>
|
||||
<div class="border timetable-container">
|
||||
<div class="bsplus-rounded timetable-container">
|
||||
<div class="home-subtitle">
|
||||
<h2 id="home-lesson-subtitle">Today's Lessons</h2>
|
||||
<div class="timetable-arrows">
|
||||
@@ -80,7 +80,7 @@ export async function loadHomePage() {
|
||||
<div class="day-container loading" id="day-container">
|
||||
</div>
|
||||
</div>
|
||||
<div class="border upcoming-container">
|
||||
<div class="bsplus-rounded upcoming-container">
|
||||
<div class="upcoming-title">
|
||||
<h2 class="home-subtitle">Upcoming Assessments</h2>
|
||||
<div class="upcoming-filters" id="upcoming-filters"></div>
|
||||
@@ -88,7 +88,7 @@ export async function loadHomePage() {
|
||||
<div class="upcoming-items loading" id="upcoming-items">
|
||||
</div>
|
||||
</div>
|
||||
<div class="border notices-container">
|
||||
<div class="bsplus-rounded notices-container">
|
||||
<div style="display: flex; justify-content: space-between">
|
||||
<h2 class="home-subtitle">Notices</h2>
|
||||
<input type="date" />
|
||||
|
||||
@@ -24,20 +24,58 @@ export interface ThemeOfTheMonthEntry {
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
const TOTM_FETCH_TIMEOUT_MS = 800;
|
||||
const TOTM_CACHE_KEY = "bsplus_theme_of_the_month_cache";
|
||||
|
||||
function isValidTotmEntry(data: unknown): data is ThemeOfTheMonthEntry {
|
||||
return (
|
||||
!!data &&
|
||||
typeof data === "object" &&
|
||||
typeof (data as ThemeOfTheMonthEntry).id === "string" &&
|
||||
!!(data as ThemeOfTheMonthEntry).id
|
||||
);
|
||||
}
|
||||
|
||||
async function readCachedThemeOfTheMonth(): Promise<ThemeOfTheMonthEntry | null> {
|
||||
try {
|
||||
const stored = await browser.storage.local.get(TOTM_CACHE_KEY);
|
||||
const cached = stored[TOTM_CACHE_KEY];
|
||||
return isValidTotmEntry(cached) ? cached : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeCachedThemeOfTheMonth(
|
||||
entry: ThemeOfTheMonthEntry,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await browser.storage.local.set({ [TOTM_CACHE_KEY]: entry });
|
||||
} catch {
|
||||
/* ignore cache write failures */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches Theme of the Month with a hard timeout so a hung API cannot stall
|
||||
* the startup popup queue. Falls back to last-good cached entry on timeout/error.
|
||||
*/
|
||||
export async function fetchThemeOfTheMonth(): Promise<ThemeOfTheMonthEntry | null> {
|
||||
try {
|
||||
const res = await fetch(`${getApiBase()}/api/theme-of-the-month/current`, {
|
||||
cache: "no-store",
|
||||
signal: AbortSignal.timeout(TOTM_FETCH_TIMEOUT_MS),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
if (!res.ok) return readCachedThemeOfTheMonth();
|
||||
const text = await res.text();
|
||||
if (!text) return null;
|
||||
if (!text) return readCachedThemeOfTheMonth();
|
||||
const data = JSON.parse(text);
|
||||
if (!data || typeof data !== "object" || !data.id) return null;
|
||||
return data as ThemeOfTheMonthEntry;
|
||||
if (!isValidTotmEntry(data)) return readCachedThemeOfTheMonth();
|
||||
void writeCachedThemeOfTheMonth(data);
|
||||
return data;
|
||||
} catch (err) {
|
||||
console.warn("[ThemeOfTheMonth] Failed to fetch current entry:", err);
|
||||
return null;
|
||||
return readCachedThemeOfTheMonth();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import browser from "webextension-polyfill";
|
||||
import { fetchThemeOfTheMonth } from "./OpenThemeOfTheMonthPopup";
|
||||
|
||||
jest.mock("../DevApiBase", () => ({
|
||||
getApiBase: () => "https://example.test",
|
||||
}));
|
||||
|
||||
const CACHE_KEY = "bsplus_theme_of_the_month_cache";
|
||||
|
||||
const sampleEntry = {
|
||||
id: "totm-1",
|
||||
month: "2026-07",
|
||||
title: "July Theme",
|
||||
description: "desc",
|
||||
cover_image: null,
|
||||
theme_id: null,
|
||||
theme: null,
|
||||
created_at: 1,
|
||||
updated_at: 1,
|
||||
};
|
||||
|
||||
describe("fetchThemeOfTheMonth", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it("caches a successful response", async () => {
|
||||
globalThis.fetch = jest.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
text: async () => JSON.stringify(sampleEntry),
|
||||
}) as typeof fetch;
|
||||
|
||||
const entry = await fetchThemeOfTheMonth();
|
||||
expect(entry?.id).toBe("totm-1");
|
||||
expect(browser.storage.local.set).toHaveBeenCalledWith({
|
||||
[CACHE_KEY]: sampleEntry,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to cache when fetch fails or times out", async () => {
|
||||
await browser.storage.local.set({ [CACHE_KEY]: sampleEntry });
|
||||
globalThis.fetch = jest
|
||||
.fn()
|
||||
.mockRejectedValue(new DOMException("Aborted", "AbortError"));
|
||||
|
||||
const entry = await fetchThemeOfTheMonth();
|
||||
expect(entry?.id).toBe("totm-1");
|
||||
});
|
||||
});
|
||||
@@ -42,7 +42,7 @@ export async function SendNewsPage() {
|
||||
const html = stringToHTML(/* html */ `
|
||||
<div class="home-root">
|
||||
<div class="home-container" id="news-container">
|
||||
<h1 class="border">Latest Headlines in ${displayCountry}</h1>
|
||||
<h1 class="bsplus-rounded">Latest Headlines in ${displayCountry}</h1>
|
||||
</div>
|
||||
</div>`);
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* @jest-environment jsdom
|
||||
*/
|
||||
import { waitForElm } from "@/seqta/utils/waitForElm";
|
||||
import {
|
||||
resetSeqtaShellWaiters,
|
||||
waitForSeqtaMenu,
|
||||
waitForSeqtaTitle,
|
||||
} from "./waitForSeqtaShell";
|
||||
|
||||
jest.mock("@/seqta/utils/waitForElm", () => ({
|
||||
waitForElm: jest.fn(),
|
||||
}));
|
||||
|
||||
describe("waitForSeqtaShell", () => {
|
||||
beforeEach(() => {
|
||||
resetSeqtaShellWaiters();
|
||||
document.body.innerHTML = "";
|
||||
jest.mocked(waitForElm).mockReset();
|
||||
});
|
||||
|
||||
it("returns existing #title without calling waitForElm", async () => {
|
||||
const el = document.createElement("div");
|
||||
el.id = "title";
|
||||
document.body.append(el);
|
||||
|
||||
await expect(waitForSeqtaTitle()).resolves.toBe(el);
|
||||
expect(waitForElm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shares one waitForElm promise for concurrent #menu waiters", async () => {
|
||||
let resolveWait!: (el: Element) => void;
|
||||
const pending = new Promise<Element>((resolve) => {
|
||||
resolveWait = resolve;
|
||||
});
|
||||
jest.mocked(waitForElm).mockReturnValue(pending);
|
||||
|
||||
const a = waitForSeqtaMenu();
|
||||
const b = waitForSeqtaMenu();
|
||||
expect(waitForElm).toHaveBeenCalledTimes(1);
|
||||
|
||||
const menu = document.createElement("div");
|
||||
menu.id = "menu";
|
||||
resolveWait(menu);
|
||||
|
||||
await expect(Promise.all([a, b])).resolves.toEqual([menu, menu]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { waitForElm } from "@/seqta/utils/waitForElm";
|
||||
|
||||
/**
|
||||
* Shared readiness promises for SEQTA chrome elements that titlebar, search,
|
||||
* and sidebar all wait on. Avoids overlapping waitForElm observers/polls.
|
||||
*/
|
||||
|
||||
type ShellKey = "title" | "menu";
|
||||
|
||||
const inflight = new Map<ShellKey, Promise<Element>>();
|
||||
|
||||
function sharedWait(
|
||||
key: ShellKey,
|
||||
selector: string,
|
||||
interval: number,
|
||||
maxIterations: number,
|
||||
): Promise<Element> {
|
||||
const existing = inflight.get(key);
|
||||
if (existing) return existing;
|
||||
|
||||
const promise = waitForElm(selector, true, interval, maxIterations).then(
|
||||
(el) => el,
|
||||
(err) => {
|
||||
inflight.delete(key);
|
||||
throw err;
|
||||
},
|
||||
);
|
||||
inflight.set(key, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
/** Resolves once `#title` exists (shared across titlebar + global search). */
|
||||
export function waitForSeqtaTitle(
|
||||
interval = 50,
|
||||
maxIterations = 200,
|
||||
): Promise<Element> {
|
||||
const immediate = document.querySelector("#title");
|
||||
if (immediate) return Promise.resolve(immediate);
|
||||
return sharedWait("title", "#title", interval, maxIterations);
|
||||
}
|
||||
|
||||
/** Resolves once `#menu` exists (shared across sidebar + background layers). */
|
||||
export function waitForSeqtaMenu(
|
||||
interval = 50,
|
||||
maxIterations = 200,
|
||||
): Promise<Element> {
|
||||
const immediate = document.querySelector("#menu");
|
||||
if (immediate) return Promise.resolve(immediate);
|
||||
return sharedWait("menu", "#menu", interval, maxIterations);
|
||||
}
|
||||
|
||||
/** Test/helpers: clear shared caches between mounts. */
|
||||
export function resetSeqtaShellWaiters(): void {
|
||||
inflight.clear();
|
||||
}
|
||||
Reference in New Issue
Block a user