mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-08-28 17:21:05 +00:00
feat: final bug fixes
This commit is contained in:
+28
-9
@@ -481,7 +481,7 @@ ul.magicDelete > li.deleting {
|
|||||||
place-items: center;
|
place-items: center;
|
||||||
}
|
}
|
||||||
#menu .sub {
|
#menu .sub {
|
||||||
top: 71.5px;
|
top: 71px;
|
||||||
margin-top: -2px;
|
margin-top: -2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -728,7 +728,7 @@ html.bsplus-custom-title-pending #title > :not(#bsplus-title-root),
|
|||||||
&::before {
|
&::before {
|
||||||
box-shadow: none !important;
|
box-shadow: none !important;
|
||||||
border-bottom: 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 {
|
#container #content::before {
|
||||||
content: "";
|
content: "";
|
||||||
height: 69.5px;
|
height: 71px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
.menuShown #container #content #title {
|
.menuShown #container #content #title {
|
||||||
@@ -1356,7 +1356,7 @@ body.student #menu > ul::before {
|
|||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
height: 69.5px;
|
height: 71px;
|
||||||
box-shadow: 0px 0px 4px 2px rgba(0, 0, 0, 0.2);
|
box-shadow: 0px 0px 4px 2px rgba(0, 0, 0, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1605,9 +1605,14 @@ html.transparencyEffects
|
|||||||
color: var(--text-primary) !important;
|
color: var(--text-primary) !important;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
box-sizing: border-box;
|
||||||
padding-right: 56px !important;
|
padding-right: 56px !important;
|
||||||
height: 54px;
|
/* Must match logo/`#content` spacer (~71px). A 54px height only "looked"
|
||||||
min-height: 48px;
|
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;
|
box-shadow: rgba(0, 0, 0, 0.35) 0px 0px 10px;
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
font-weight: 500 !important;
|
font-weight: 500 !important;
|
||||||
@@ -1620,12 +1625,13 @@ html.transparencyEffects
|
|||||||
|
|
||||||
#title::before {
|
#title::before {
|
||||||
background-image: var(--betterseqta-logo);
|
background-image: var(--betterseqta-logo);
|
||||||
background-position: left;
|
background-position: left center;
|
||||||
background-repeat: no-repeat;
|
background-repeat: no-repeat;
|
||||||
background-size: auto 48px;
|
background-size: auto 48px;
|
||||||
height: 69.5px;
|
height: 69.5px;
|
||||||
content: "";
|
content: "";
|
||||||
width: 184px;
|
width: 184px;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.rightElements {
|
.rightElements {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1648,10 +1654,20 @@ html.transparencyEffects
|
|||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
}
|
}
|
||||||
.timetable-arrows {
|
.timetable-arrows {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
gap: 4px;
|
||||||
padding: 5px;
|
padding: 5px;
|
||||||
}
|
}
|
||||||
.timetable-arrows svg {
|
.timetable-arrows svg {
|
||||||
margin: 0 5px;
|
display: block;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
margin: 0;
|
||||||
|
position: static;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.timetable-arrows svg:hover {
|
.timetable-arrows svg:hover {
|
||||||
@@ -1801,9 +1817,12 @@ html.transparencyEffects
|
|||||||
-webkit-box-shadow: 0px 5px 16px 6px rgba(0, 0, 0, 0.3);
|
-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);
|
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;
|
border-radius: 16px;
|
||||||
overflow: clip;
|
overflow: clip;
|
||||||
|
border-width: 0;
|
||||||
}
|
}
|
||||||
.shortcut-container h2 {
|
.shortcut-container h2 {
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
|
|||||||
@@ -20,6 +20,37 @@
|
|||||||
animation: none !important;
|
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 {
|
#menu li.hasChildren:not(.active) li .sub {
|
||||||
display: none; // improves performance
|
display: none; // improves performance
|
||||||
}
|
}
|
||||||
@@ -38,6 +69,13 @@
|
|||||||
transition: transform 0.3s ease;
|
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 {
|
#menu .sub {
|
||||||
transition: transform 0.3s ease, left 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
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 {
|
body.icon-only-sidebar:not(:has(#menu li.hasChildren.active)) #menu > ul ul:has(li.hasChildren.active) > li > svg {
|
||||||
transform: translateX(-70px);
|
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 */
|
/* Blurs */
|
||||||
.search,
|
.search,
|
||||||
.document,
|
.document,
|
||||||
.border,
|
.bsplus-rounded,
|
||||||
.draggable,
|
.draggable,
|
||||||
.notice,
|
.notice,
|
||||||
[class*="BasicPanel__BasicPanel___"],
|
[class*="BasicPanel__BasicPanel___"],
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { PluginAPI } from "@/plugins/core/types";
|
import type { PluginAPI } from "@/plugins/core/types";
|
||||||
import { waitForElm } from "@/seqta/utils/waitForElm";
|
import { waitForElm } from "@/seqta/utils/waitForElm";
|
||||||
|
import { waitForSeqtaMenu } from "@/seqta/utils/waitForSeqtaShell";
|
||||||
|
|
||||||
export const ANIMATED_BG_MARKER = "bsplus-animated-bg";
|
export const ANIMATED_BG_MARKER = "bsplus-animated-bg";
|
||||||
|
|
||||||
@@ -51,7 +52,7 @@ export async function syncAnimatedBackground(
|
|||||||
try {
|
try {
|
||||||
const [container, menu] = await Promise.all([
|
const [container, menu] = await Promise.all([
|
||||||
waitForElm("#container", true),
|
waitForElm("#container", true),
|
||||||
waitForElm("#menu", true),
|
waitForSeqtaMenu(),
|
||||||
]);
|
]);
|
||||||
ensureAnimatedBackgroundLayers(
|
ensureAnimatedBackgroundLayers(
|
||||||
container as HTMLElement,
|
container as HTMLElement,
|
||||||
|
|||||||
@@ -14,11 +14,15 @@
|
|||||||
let outputUnit = $state<string>('');
|
let outputUnit = $state<string>('');
|
||||||
let isPartial = $state(false);
|
let isPartial = $state(false);
|
||||||
|
|
||||||
const processInput = (input: string) => {
|
let calcGen = 0;
|
||||||
|
|
||||||
|
const processInput = async (input: string) => {
|
||||||
|
const gen = ++calcGen;
|
||||||
isCalculating = true;
|
isCalculating = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const calcResult = calculateExpression(input);
|
const calcResult = await calculateExpression(input);
|
||||||
|
if (gen !== calcGen) return;
|
||||||
|
|
||||||
if (calcResult.isValid) {
|
if (calcResult.isValid) {
|
||||||
result = calcResult.result;
|
result = calcResult.result;
|
||||||
@@ -33,19 +37,20 @@
|
|||||||
isPartial = false;
|
isPartial = false;
|
||||||
dispatch('hasResult', null);
|
dispatch('hasResult', null);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch {
|
||||||
|
if (gen !== calcGen) return;
|
||||||
result = null;
|
result = null;
|
||||||
inputUnit = '';
|
inputUnit = '';
|
||||||
outputUnit = '';
|
outputUnit = '';
|
||||||
isPartial = false;
|
isPartial = false;
|
||||||
dispatch('hasResult', null);
|
dispatch('hasResult', null);
|
||||||
} finally {
|
} finally {
|
||||||
isCalculating = false;
|
if (gen === calcGen) isCalculating = false;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
processInput(searchTerm);
|
void processInput(searchTerm);
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
|
|||||||
@@ -114,7 +114,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const itemsUpdatedHandler = (event: Event) => {
|
const applyItemsUpdate = (event: Event) => {
|
||||||
const detail = (event as CustomEvent<DynamicItemsUpdatedDetail>).detail;
|
const detail = (event as CustomEvent<DynamicItemsUpdatedDetail>).detail;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -142,6 +142,21 @@
|
|||||||
setupSearchIndexes();
|
setupSearchIndexes();
|
||||||
performSearch();
|
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);
|
window.addEventListener('dynamic-items-updated', itemsUpdatedHandler);
|
||||||
|
|
||||||
setupSearchIndexes();
|
setupSearchIndexes();
|
||||||
@@ -153,6 +168,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
if (itemsUpdateRaf) cancelAnimationFrame(itemsUpdateRaf);
|
||||||
window.removeEventListener('dynamic-items-updated', itemsUpdatedHandler);
|
window.removeEventListener('dynamic-items-updated', itemsUpdatedHandler);
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Plugin } from "@/plugins/core/types";
|
import type { Plugin } from "@/plugins/core/types";
|
||||||
import { verboseDebug, verboseLog } from "@/utils/verboseLog";
|
import { verboseDebug, verboseLog } from "@/utils/verboseLog";
|
||||||
import styles from "./styles.css?inline";
|
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 { runIndexing, ensureSchemaCurrent } from "../indexing/indexer";
|
||||||
import { installResetIndexMessageListener } from "../indexing/resetIndexes";
|
import { installResetIndexMessageListener } from "../indexing/resetIndexes";
|
||||||
import { isIndexingPaused } from "../indexing/indexingPause";
|
import { isIndexingPaused } from "../indexing/indexingPause";
|
||||||
@@ -13,6 +13,14 @@ import {
|
|||||||
installPassiveObserver,
|
installPassiveObserver,
|
||||||
} from "../indexing/passiveObserver";
|
} from "../indexing/passiveObserver";
|
||||||
|
|
||||||
|
function scheduleIdleIndexing(run: () => void): void {
|
||||||
|
if (typeof requestIdleCallback === "function") {
|
||||||
|
requestIdleCallback(() => run(), { timeout: 5000 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTimeout(run, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
const globalSearchPlugin: Plugin<{}> = {
|
const globalSearchPlugin: Plugin<{}> = {
|
||||||
id: "global-search",
|
id: "global-search",
|
||||||
name: "Global Search",
|
name: "Global Search",
|
||||||
@@ -84,17 +92,12 @@ const globalSearchPlugin: Plugin<{}> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (api.settings.runIndexingOnLoad && !isIndexingPaused()) {
|
if (api.settings.runIndexingOnLoad && !isIndexingPaused()) {
|
||||||
setTimeout(async () => {
|
scheduleIdleIndexing(() => {
|
||||||
if (!isIndexingPaused()) await runIndexing();
|
if (!isIndexingPaused()) void runIndexing();
|
||||||
}, 2000);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const title = document.querySelector("#title");
|
void mountSearchBar(await waitForSeqtaTitle(100, 60), api, appRef);
|
||||||
if (title) {
|
|
||||||
void mountSearchBar(title, api, appRef);
|
|
||||||
} else {
|
|
||||||
void mountSearchBar(await waitForElm("#title", true, 100, 60), api, appRef);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => cleanupSearchBar(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';
|
import { unitFullNames } from './unitMap';
|
||||||
|
|
||||||
export interface CalculatorResult {
|
export interface CalculatorResult {
|
||||||
@@ -18,45 +11,106 @@ export interface CalculatorResult {
|
|||||||
|
|
||||||
export const CALCULATOR_MAX_INPUT_LENGTH = 128;
|
export const CALCULATOR_MAX_INPUT_LENGTH = 128;
|
||||||
|
|
||||||
const CALCULATOR_MATH_CONFIG = Object.assign(
|
type CalculatorMath = {
|
||||||
{},
|
evaluate: (expr: string) => unknown;
|
||||||
evaluateDependencies, formatDependencies, unitDependencies, typeOfDependencies,
|
format: (value: unknown, opts: object) => string;
|
||||||
addDependencies, subtractDependencies, multiplyDependencies, divideDependencies,
|
typeOf: (value: unknown) => string;
|
||||||
powDependencies, modDependencies, unaryMinusDependencies, unaryPlusDependencies,
|
unit: (expression: string) => { formatUnits: () => string } | null;
|
||||||
absDependencies, sqrtDependencies, logDependencies, log10Dependencies,
|
import: (fns: Record<string, () => never>, opts: { override: boolean }) => void;
|
||||||
sinDependencies, cosDependencies, tanDependencies, toDependencies,
|
};
|
||||||
piDependencies, eDependencies,
|
|
||||||
);
|
|
||||||
|
|
||||||
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 FORMAT_OPTS = { precision: 14, lowerExp: -15, upperExp: 15 } as const;
|
||||||
const emptyResult = (error?: string): CalculatorResult => ({
|
const emptyResult = (error?: string): CalculatorResult => ({
|
||||||
result: null, isValid: false, isPartial: false, inputUnit: '', outputUnit: '', error,
|
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 {
|
try {
|
||||||
const unit = calculatorMath.unit(expression);
|
const unit = math.unit(expression);
|
||||||
if (unit) {
|
if (unit) {
|
||||||
const unitStr = unit.formatUnits();
|
const unitStr = unit.formatUnits();
|
||||||
return unitFullNames[unitStr] || unitStr;
|
return unitFullNames[unitStr] || unitStr;
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
return '';
|
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();
|
const trimmed = input.trim();
|
||||||
if (!/[\d+\-*/^()=.]/.test(trimmed)) return false;
|
if (!/[\d+\-*/^()=.]/.test(trimmed)) return false;
|
||||||
const nonMathWords = ['abs', 'function', 'class', 'const', 'let', 'var', 'if', 'else', 'while', 'for', 'return', 'import', 'export'];
|
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);
|
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();
|
const trimmed = expression.trim();
|
||||||
for (const pattern of [/[\+\-\*\/\^]\s*$/, /\(\s*$/, /[\+\-\*\/\^]\s*\(/]) {
|
for (const pattern of [/[\+\-\*\/\^]\s*$/, /\(\s*$/, /[\+\-\*\/\^]\s*\(/]) {
|
||||||
if (!pattern.test(trimmed)) continue;
|
if (!pattern.test(trimmed)) continue;
|
||||||
const partial = trimmed.replace(/[\+\-\*\/\^]\s*$/, '').trim();
|
const partial = trimmed.replace(/[\+\-\*\/\^]\s*$/, '').trim();
|
||||||
if (!partial || partial.match(/[\+\-\*\/\^]\s*$/)) continue;
|
if (!partial || partial.match(/[\+\-\*\/\^]\s*$/)) continue;
|
||||||
try {
|
try {
|
||||||
const result = calculatorMath.evaluate(partial);
|
const result = math.evaluate(partial);
|
||||||
if (typeof result === 'number' && !isNaN(result)) {
|
if (typeof result === 'number' && !isNaN(result)) {
|
||||||
return calculatorMath.format(result, FORMAT_OPTS);
|
return math.format(result, FORMAT_OPTS);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
}
|
}
|
||||||
} catch {}
|
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function calculateExpression(input: string): CalculatorResult {
|
export async function calculateExpression(input: string): Promise<CalculatorResult> {
|
||||||
const trimmed = input.trim();
|
const trimmed = input.trim();
|
||||||
if (!trimmed || (trimmed.length <= 2 && !/\d/.test(trimmed))) return emptyResult();
|
if (!trimmed || (trimmed.length <= 2 && !/\d/.test(trimmed))) return emptyResult();
|
||||||
if (trimmed.length > CALCULATOR_MAX_INPUT_LENGTH) {
|
if (trimmed.length > CALCULATOR_MAX_INPUT_LENGTH) {
|
||||||
@@ -89,21 +145,23 @@ export function calculateExpression(input: string): CalculatorResult {
|
|||||||
}
|
}
|
||||||
if (!isLikelyMathExpression(trimmed)) return emptyResult();
|
if (!isLikelyMathExpression(trimmed)) return emptyResult();
|
||||||
|
|
||||||
|
const math = await getCalculatorMath();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const evaluated = calculatorMath.evaluate(trimmed.replace('**', '^'));
|
const evaluated = math.evaluate(trimmed.replace('**', '^'));
|
||||||
if (evaluated !== undefined) {
|
if (evaluated !== undefined) {
|
||||||
const result = calculatorMath.format(evaluated, FORMAT_OPTS);
|
const result = math.format(evaluated, FORMAT_OPTS);
|
||||||
const isUnit = calculatorMath.typeOf(evaluated) === 'Unit';
|
const isUnit = math.typeOf(evaluated) === 'Unit';
|
||||||
return {
|
return {
|
||||||
result,
|
result,
|
||||||
isValid: true,
|
isValid: true,
|
||||||
isPartial: false,
|
isPartial: false,
|
||||||
inputUnit: isUnit ? detectUnit(trimmed) : '',
|
inputUnit: isUnit ? detectUnit(math, trimmed) : '',
|
||||||
outputUnit: isUnit ? detectUnit(result) : '',
|
outputUnit: isUnit ? detectUnit(math, result) : '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const partialResult = tryCompleteExpression(trimmed);
|
const partialResult = tryCompleteExpression(math, trimmed);
|
||||||
if (partialResult) {
|
if (partialResult) {
|
||||||
return { result: partialResult, isValid: true, isPartial: true, inputUnit: '', outputUnit: '' };
|
return { result: partialResult, isValid: true, isPartial: true, inputUnit: '', outputUnit: '' };
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-2
@@ -1,6 +1,5 @@
|
|||||||
// Third-party libraries
|
// Third-party libraries
|
||||||
import browser from "webextension-polyfill";
|
import browser from "webextension-polyfill";
|
||||||
import { animate, stagger } from "motion";
|
|
||||||
|
|
||||||
// Internal utilities and functions
|
// Internal utilities and functions
|
||||||
import { GetThresholdOfColor } from "@/seqta/ui/colors/getThresholdColour";
|
import { GetThresholdOfColor } from "@/seqta/ui/colors/getThresholdColour";
|
||||||
@@ -48,6 +47,11 @@ import iframeCSS from "@/css/iframe.scss?raw";
|
|||||||
|
|
||||||
import { applyMenuItemVisibility } from "@/seqta/utils/menuItemVisibility";
|
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() {
|
export function hideSideBar() {
|
||||||
const sidebar = document.getElementById("menu"); // The sidebar element to be closed
|
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
|
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
|
// get index of node in relation to parent
|
||||||
const index = Array.from(node.parentElement!.children).indexOf(node);
|
const index = Array.from(node.parentElement!.children).indexOf(node);
|
||||||
|
|
||||||
|
const { animate } = await loadMotion();
|
||||||
animate(
|
animate(
|
||||||
node,
|
node,
|
||||||
{ opacity: [0, 1], y: [50, 0], scale: [0.99, 1] },
|
{ 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(
|
const messages = Array.from(
|
||||||
document.querySelectorAll("[data-message]"),
|
document.querySelectorAll("[data-message]"),
|
||||||
).slice(0, 35);
|
).slice(0, 35);
|
||||||
|
const { animate, stagger } = await loadMotion();
|
||||||
animate(
|
animate(
|
||||||
messages,
|
messages,
|
||||||
{ opacity: [0, 1], y: [10, 0] },
|
{ opacity: [0, 1], y: [10, 0] },
|
||||||
@@ -397,6 +403,7 @@ async function handleDashboard(node: Element): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
const children = document.querySelectorAll(".dashboard > *");
|
const children = document.querySelectorAll(".dashboard > *");
|
||||||
if (children.length) {
|
if (children.length) {
|
||||||
|
const { animate, stagger } = await loadMotion();
|
||||||
animate(
|
animate(
|
||||||
children,
|
children,
|
||||||
{ opacity: [0, 1], y: [10, 0] },
|
{ opacity: [0, 1], y: [10, 0] },
|
||||||
@@ -422,6 +429,7 @@ async function handleDocuments(node: Element): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
const rows = document.querySelectorAll(".documents tbody tr.document");
|
const rows = document.querySelectorAll(".documents tbody tr.document");
|
||||||
if (rows.length) {
|
if (rows.length) {
|
||||||
|
const { animate, stagger } = await loadMotion();
|
||||||
animate(
|
animate(
|
||||||
rows,
|
rows,
|
||||||
{ opacity: [0, 1], y: [10, 0] },
|
{ opacity: [0, 1], y: [10, 0] },
|
||||||
@@ -445,6 +453,7 @@ async function handleReports(node: Element): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
const items = document.querySelectorAll(".reports .item");
|
const items = document.querySelectorAll(".reports .item");
|
||||||
if (items.length) {
|
if (items.length) {
|
||||||
|
const { animate, stagger } = await loadMotion();
|
||||||
animate(
|
animate(
|
||||||
items,
|
items,
|
||||||
{ opacity: [0, 1], y: [10, 0] },
|
{ opacity: [0, 1], y: [10, 0] },
|
||||||
@@ -647,8 +656,14 @@ export function showConflictPopup() {
|
|||||||
|
|
||||||
document.getElementById("container")?.append(background);
|
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) {
|
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) => {
|
background.addEventListener("click", (event) => {
|
||||||
|
|||||||
@@ -48,7 +48,7 @@
|
|||||||
tabindex={editMode ? -1 : 0}
|
tabindex={editMode ? -1 : 0}
|
||||||
aria-label={item.label}
|
aria-label={item.label}
|
||||||
aria-current={active ? "page" : undefined}
|
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) => {
|
onclick={(e) => {
|
||||||
// Keep SEQTA's #menu handlers from seeing custom-list clicks — that fights
|
// Keep SEQTA's #menu handlers from seeing custom-list clicks — that fights
|
||||||
// our drill UI and can freeze the tab (Goals / Folios / etc.).
|
// 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 { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||||
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
|
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
|
||||||
import { waitForElm } from "@/seqta/utils/waitForElm";
|
import { waitForElm } from "@/seqta/utils/waitForElm";
|
||||||
|
import { waitForSeqtaMenu } from "@/seqta/utils/waitForSeqtaShell";
|
||||||
import Sidebar from "./Sidebar.svelte";
|
import Sidebar from "./Sidebar.svelte";
|
||||||
import { getNativeMenuList } from "./parseNativeMenu";
|
import { getNativeMenuList } from "./parseNativeMenu";
|
||||||
import {
|
import {
|
||||||
@@ -28,30 +29,62 @@ let syncTimer: ReturnType<typeof setTimeout> | null = null;
|
|||||||
let hashListenerAttached = false;
|
let hashListenerAttached = false;
|
||||||
let sidebarCaptureAttached = false;
|
let sidebarCaptureAttached = false;
|
||||||
let earlyPrepareStarted = false;
|
let earlyPrepareStarted = false;
|
||||||
let catchupTimer: ReturnType<typeof setInterval> | null = null;
|
let catchupTimers: ReturnType<typeof setTimeout>[] = [];
|
||||||
let nativeMenuListenerAttached = false;
|
let nativeMenuListenerAttached = false;
|
||||||
|
let lastMenuFingerprint = "";
|
||||||
|
|
||||||
const settingsListeners: Array<{
|
const settingsListeners: Array<{
|
||||||
key: keyof SettingsState;
|
key: keyof SettingsState;
|
||||||
listener: ChangeListener;
|
listener: ChangeListener;
|
||||||
}> = [];
|
}> = [];
|
||||||
|
|
||||||
function syncFromMenu() {
|
/** Stable fingerprint of native menu keys so unchanged DOM does not re-sync. */
|
||||||
if (menuEl) sidebarState.syncFromNative(menuEl);
|
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() {
|
function syncFromMenu(force = false) {
|
||||||
if (catchupTimer) clearInterval(catchupTimer);
|
if (!menuEl) return;
|
||||||
let attempts = 0;
|
const next = menuFingerprint(menuEl);
|
||||||
catchupTimer = setInterval(() => {
|
if (!force && next === lastMenuFingerprint) return;
|
||||||
attempts += 1;
|
lastMenuFingerprint = next;
|
||||||
syncFromMenu();
|
sidebarState.syncFromNative(menuEl);
|
||||||
// Plugins (Analytics, Overview, icons) inject shortly after first paint.
|
|
||||||
if (attempts >= 60) {
|
|
||||||
clearInterval(catchupTimer!);
|
|
||||||
catchupTimer = null;
|
|
||||||
}
|
}
|
||||||
}, 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() {
|
function scheduleSync() {
|
||||||
@@ -201,7 +234,12 @@ export async function mountCustomSidebar(): Promise<boolean> {
|
|||||||
|
|
||||||
document.documentElement.classList.add(PENDING_CLASS);
|
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;
|
if (!menu) return false;
|
||||||
|
|
||||||
// Prefer a populated native list, but don't block forever during loading.
|
// Prefer a populated native list, but don't block forever during loading.
|
||||||
@@ -270,7 +308,7 @@ export async function mountCustomSidebar(): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!nativeMenuListenerAttached) {
|
if (!nativeMenuListenerAttached) {
|
||||||
window.addEventListener("bsplus-native-menu-updated", syncFromMenu);
|
window.addEventListener("bsplus-native-menu-updated", onNativeMenuUpdated);
|
||||||
nativeMenuListenerAttached = true;
|
nativeMenuListenerAttached = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,7 +326,7 @@ export async function mountCustomSidebar(): Promise<boolean> {
|
|||||||
] as const) {
|
] as const) {
|
||||||
registerSetting(key, () => applySidebarLook(menuEl));
|
registerSetting(key, () => applySidebarLook(menuEl));
|
||||||
}
|
}
|
||||||
const resync = () => syncFromMenu();
|
const resync = () => syncFromMenu(true);
|
||||||
registerSetting("menuorder", resync);
|
registerSetting("menuorder", resync);
|
||||||
registerSetting("menuitems", resync);
|
registerSetting("menuitems", resync);
|
||||||
|
|
||||||
@@ -302,8 +340,9 @@ export function unmountCustomSidebar() {
|
|||||||
menuObserver = null;
|
menuObserver = null;
|
||||||
if (syncTimer) clearTimeout(syncTimer);
|
if (syncTimer) clearTimeout(syncTimer);
|
||||||
syncTimer = null;
|
syncTimer = null;
|
||||||
if (catchupTimer) clearInterval(catchupTimer);
|
for (const t of catchupTimers) clearTimeout(t);
|
||||||
catchupTimer = null;
|
catchupTimers = [];
|
||||||
|
lastMenuFingerprint = "";
|
||||||
|
|
||||||
clearSettingListeners();
|
clearSettingListeners();
|
||||||
|
|
||||||
@@ -318,7 +357,7 @@ export function unmountCustomSidebar() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (nativeMenuListenerAttached) {
|
if (nativeMenuListenerAttached) {
|
||||||
window.removeEventListener("bsplus-native-menu-updated", syncFromMenu);
|
window.removeEventListener("bsplus-native-menu-updated", onNativeMenuUpdated);
|
||||||
nativeMenuListenerAttached = false;
|
nativeMenuListenerAttached = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { mount, unmount } from "svelte";
|
import { mount, unmount } from "svelte";
|
||||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||||
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
|
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
|
||||||
import { waitForElm } from "@/seqta/utils/waitForElm";
|
import { waitForSeqtaTitle } from "@/seqta/utils/waitForSeqtaShell";
|
||||||
import TitleBar from "./TitleBar.svelte";
|
import TitleBar from "./TitleBar.svelte";
|
||||||
import { titleBarState } from "./titleBarState.svelte";
|
import { titleBarState } from "./titleBarState.svelte";
|
||||||
|
|
||||||
@@ -105,7 +105,7 @@ export async function mountCustomTitleBar(): Promise<boolean> {
|
|||||||
|
|
||||||
let title: HTMLElement;
|
let title: HTMLElement;
|
||||||
try {
|
try {
|
||||||
title = (await waitForElm("#title", true, 50, 200)) as HTMLElement;
|
title = (await waitForSeqtaTitle(50, 200)) as HTMLElement;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -311,10 +311,10 @@ export async function loadEngageHomePage(): Promise<void> {
|
|||||||
const engageHomeBody = stringToHTML(/* html */ `
|
const engageHomeBody = stringToHTML(/* html */ `
|
||||||
<div class="home-root" id="engage-home-root">
|
<div class="home-root" id="engage-home-root">
|
||||||
<div class="home-container" id="engage-home-container">
|
<div class="home-container" id="engage-home-container">
|
||||||
<div class="border shortcut-container">
|
<div class="bsplus-rounded shortcut-container">
|
||||||
<div class="border shortcuts" id="shortcuts"></div>
|
<div class="bsplus-rounded shortcuts" id="shortcuts"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="border timetable-container">
|
<div class="bsplus-rounded timetable-container">
|
||||||
<div class="home-subtitle">
|
<div class="home-subtitle">
|
||||||
<div class="engage-timetable-title-cluster">
|
<div class="engage-timetable-title-cluster">
|
||||||
<h2 id="engage-home-lesson-subtitle">Today's Lessons</h2>
|
<h2 id="engage-home-lesson-subtitle">Today's Lessons</h2>
|
||||||
@@ -331,7 +331,7 @@ export async function loadEngageHomePage(): Promise<void> {
|
|||||||
</div>
|
</div>
|
||||||
<div class="day-container loading" id="engage-day-container"></div>
|
<div class="day-container loading" id="engage-day-container"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="border notices-container">
|
<div class="bsplus-rounded notices-container">
|
||||||
<div style="display: flex; justify-content: space-between">
|
<div style="display: flex; justify-content: space-between">
|
||||||
<h2 class="home-subtitle">Notices</h2>
|
<h2 class="home-subtitle">Notices</h2>
|
||||||
<input type="date" id="engage-notices-date" />
|
<input type="date" id="engage-notices-date" />
|
||||||
|
|||||||
@@ -62,10 +62,10 @@ export async function loadHomePage() {
|
|||||||
|
|
||||||
const skeletonStructure = stringToHTML(/* html */ `
|
const skeletonStructure = stringToHTML(/* html */ `
|
||||||
<div class="home-container" id="home-container">
|
<div class="home-container" id="home-container">
|
||||||
<div class="border shortcut-container">
|
<div class="bsplus-rounded shortcut-container">
|
||||||
<div class="border shortcuts" id="shortcuts"></div>
|
<div class="bsplus-rounded shortcuts" id="shortcuts"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="border timetable-container">
|
<div class="bsplus-rounded timetable-container">
|
||||||
<div class="home-subtitle">
|
<div class="home-subtitle">
|
||||||
<h2 id="home-lesson-subtitle">Today's Lessons</h2>
|
<h2 id="home-lesson-subtitle">Today's Lessons</h2>
|
||||||
<div class="timetable-arrows">
|
<div class="timetable-arrows">
|
||||||
@@ -80,7 +80,7 @@ export async function loadHomePage() {
|
|||||||
<div class="day-container loading" id="day-container">
|
<div class="day-container loading" id="day-container">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="border upcoming-container">
|
<div class="bsplus-rounded upcoming-container">
|
||||||
<div class="upcoming-title">
|
<div class="upcoming-title">
|
||||||
<h2 class="home-subtitle">Upcoming Assessments</h2>
|
<h2 class="home-subtitle">Upcoming Assessments</h2>
|
||||||
<div class="upcoming-filters" id="upcoming-filters"></div>
|
<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 class="upcoming-items loading" id="upcoming-items">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="border notices-container">
|
<div class="bsplus-rounded notices-container">
|
||||||
<div style="display: flex; justify-content: space-between">
|
<div style="display: flex; justify-content: space-between">
|
||||||
<h2 class="home-subtitle">Notices</h2>
|
<h2 class="home-subtitle">Notices</h2>
|
||||||
<input type="date" />
|
<input type="date" />
|
||||||
|
|||||||
@@ -24,20 +24,58 @@ export interface ThemeOfTheMonthEntry {
|
|||||||
updated_at: number;
|
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> {
|
export async function fetchThemeOfTheMonth(): Promise<ThemeOfTheMonthEntry | null> {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${getApiBase()}/api/theme-of-the-month/current`, {
|
const res = await fetch(`${getApiBase()}/api/theme-of-the-month/current`, {
|
||||||
cache: "no-store",
|
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();
|
const text = await res.text();
|
||||||
if (!text) return null;
|
if (!text) return readCachedThemeOfTheMonth();
|
||||||
const data = JSON.parse(text);
|
const data = JSON.parse(text);
|
||||||
if (!data || typeof data !== "object" || !data.id) return null;
|
if (!isValidTotmEntry(data)) return readCachedThemeOfTheMonth();
|
||||||
return data as ThemeOfTheMonthEntry;
|
void writeCachedThemeOfTheMonth(data);
|
||||||
|
return data;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("[ThemeOfTheMonth] Failed to fetch current entry:", 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 */ `
|
const html = stringToHTML(/* html */ `
|
||||||
<div class="home-root">
|
<div class="home-root">
|
||||||
<div class="home-container" id="news-container">
|
<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>
|
||||||
</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