feat: sidebar cleanup + fixes

This commit is contained in:
2026-07-21 09:20:53 +09:30
parent c439d8d88f
commit e732011f1e
11 changed files with 218 additions and 230 deletions
+30 -27
View File
@@ -635,16 +635,14 @@ html.bsplus-custom-sidebar-pending #menu > .icon-cover,
clip: rect(0, 0, 0, 0) !important;
}
/* Custom Svelte sidebar: SEQTA-like `#menu > ul > li.item` / `.sub` for theme CSS. */
/* Custom Svelte sidebar: keep `#menu > ul > li.item` / `.sub` shape for theme CSS. */
#menu.bsplus-custom-sidebar {
/* SEQTA uses absolute top/bottom:0 — do not switch to relative or height collapses. */
position: absolute !important;
top: 0 !important;
bottom: 0 !important;
left: 0 !important;
display: flex !important;
flex-direction: column !important;
/* No padding-top: logo `ul::before` + theme `.sub { top: 71.5px }` match native. */
box-sizing: border-box !important;
height: auto !important;
max-height: none !important;
@@ -659,23 +657,20 @@ html.bsplus-custom-sidebar-pending #menu > .icon-cover,
position: relative;
}
/* Theme drill panels assume static hasChildren + absolute .sub filling #menu. */
li.hasChildren {
position: static !important;
}
/*
* Native SEQTA leaves `li.hasChildren.active` on the hidden list after click-through.
* Global `#menu:has(> ul > li.hasChildren.active) > ul > li:not(.hasChildren.active)`
* then sets pointer-events:none on our custom root rows. Override that here; drill
* locking only follows the custom list's own active folder.
*/
/* Native list may keep `.active` after click-through; force our rows clickable,
then lock siblings only while a custom drill `.sub` is open. */
> #bsplus-sidebar-root > li.item {
pointer-events: auto !important;
width: auto !important;
max-width: none !important;
margin: 2px 6px !important;
box-sizing: border-box !important;
}
/* Only lock siblings while a drill `.sub` is open — not when a folder is
merely route-highlighted with `.active` at the root list. */
&:has(> #bsplus-sidebar-root > li.hasChildren.active > .sub)
> #bsplus-sidebar-root
> li:not(.hasChildren.active),
@@ -692,40 +687,42 @@ html.bsplus-custom-sidebar-pending #menu > .icon-cover,
> #bsplus-sidebar-root li.hasChildren.active > .sub {
pointer-events: auto;
/* Let the #menu chrome (gradient / theme) show through — themes often paint
`.sub` with flat `--background-primary` which reads as a white slab. */
background: transparent !important;
background-image: none !important;
animation: bsplus-submenu-enter 0.32s cubic-bezier(0.22, 1, 0.36, 1);
}
> #bsplus-sidebar-root li.hasChildren.active > .sub:has(.hasChildren.active) {
pointer-events: none !important;
animation: none;
}
> #bsplus-sidebar-root li.hasChildren.active .hasChildren.active > .sub {
pointer-events: auto !important;
background: transparent !important;
background-image: none !important;
}
/* One-shot enter — not tied to `.active` (SEQTA strips/restores that on every
nested click, which would restart the animation). */
> #bsplus-sidebar-root .sub.bsplus-sub-enter {
animation: bsplus-submenu-enter 0.32s cubic-bezier(0.22, 1, 0.36, 1);
}
/* Keep folder + leaf rows on the same rhythm (themes often only style
`:not(.hasChildren)`, which left folders centred / differently spaced). */
> #bsplus-sidebar-root li.item {
width: auto !important;
max-width: none !important;
margin: 2px 6px !important;
box-sizing: border-box !important;
}
/* Folder label stays in flow so sidebar-animation translateX can run.
Visually covered by the absolute `.sub` panel. */
> #bsplus-sidebar-root > li.hasChildren.active > label {
pointer-events: none !important;
}
/* Theme decorations on root leaves (Beach palm/sand) must not show through
the transparent drill `.sub` panel. */
> #bsplus-sidebar-root:has(> li.hasChildren.active > .sub)
> li:not(.hasChildren.active)::before,
> #bsplus-sidebar-root:has(> li.hasChildren.active > .sub)
> li:not(.hasChildren.active)::after {
content: none !important;
display: none !important;
animation: none !important;
opacity: 0 !important;
}
#bsplus-sidebar-root .sub .back {
display: flex;
align-items: center;
@@ -849,6 +846,12 @@ body.icon-only-sidebar:not(:has(#menu li.hasChildren.active)) {
display: none !important;
}
/* Compact BetterSEQTA mark instead of the full wordmark. */
&.student #menu > ul::before {
background-size: 36px 36px !important;
background-position: center center !important;
}
}
[class*="notifications__items___"] {
-ms-overflow-style: none !important;
+5 -1
View File
@@ -8,12 +8,16 @@
}
#menu > ul:has(li.hasChildren.active) > li::before,
#menu > ul:has(li.hasChildren.active) > li::after,
#menu > ul ul:has(li.hasChildren.active) > li::before,
#menu > ul ul:has(li.hasChildren.active) > li::after,
#menu > ul:has(li.hasChildren.active) > li > label,
#menu > ul:has(li.hasChildren.active) > li > svg,
#menu > ul ul:has(li.hasChildren.active) > li > label,
#menu > ul ul:has(li.hasChildren.active) > li > svg {
transform: translateX(-320px);
/* !important beats theme animations (e.g. Beach palm-slide forwards). */
transform: translateX(-320px) !important;
animation: none !important;
}
#menu li.hasChildren:not(.active) li .sub {
@@ -10,19 +10,12 @@ import { ChangeMenuItemPositions } from "@/seqta/utils/Openers/menuOrder";
import { isMenuOptionsOpen } from "@/seqta/utils/Openers/menuOptionsState";
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import { applyMenuItemVisibility } from "@/seqta/utils/menuItemVisibility";
import { getNativeMenuList } from "@/seqta/ui/sidebar/parseNativeMenu";
const ANALYTICS_MENU_ICON = MenuitemSVGKey.analytics;
export const ANALYTICS_MENU_CLASS = "betterseqta-grade-analytics-item";
export const ANALYTICS_MENU_KEY = "analytics";
function getNativeMenuList(): HTMLElement | null {
const menu = document.getElementById("menu");
if (!menu) return null;
return menu.querySelector(
":scope > ul:not(#bsplus-sidebar-root)",
) as HTMLElement | null;
}
function loadAnalyticsPageLazy() {
void import("./loadAnalyticsPage").then((m) => m.loadAnalyticsPage());
}
+9 -2
View File
@@ -10,6 +10,8 @@ import { getCustomThemeAdaptiveCssVariableBindings } from "@/seqta/ui/colors/cus
import { resolveExtensionAssetUrl } from "@/lib/extensionAssetUrl";
import darkLogo from "@/resources/icons/betterseqta-light-full.png";
import lightLogo from "@/resources/icons/betterseqta-dark-full.png";
import darkIcon from "@/resources/icons/betterseqta-light-icon.png";
import lightIcon from "@/resources/icons/betterseqta-dark-icon.png";
const ADAPTIVE_THEME_TRANSITION_MS = 400;
const LOGO_STYLE_ID = "bsplus-logo-style";
@@ -88,7 +90,8 @@ function cancelColorTransition() {
/** Chromium does not always resolve extension URLs inside CSS variables on ::before. */
function applyBetterseqtaLogoBackground(isDark: boolean) {
const url = resolveExtensionAssetUrl(isDark ? darkLogo : lightLogo);
const fullUrl = resolveExtensionAssetUrl(isDark ? darkLogo : lightLogo);
const iconUrl = resolveExtensionAssetUrl(isDark ? darkIcon : lightIcon);
let styleEl = document.getElementById(LOGO_STYLE_ID);
if (!styleEl) {
styleEl = document.createElement("style");
@@ -98,7 +101,11 @@ function applyBetterseqtaLogoBackground(isDark: boolean) {
styleEl.textContent = `
body.student #menu > ul::before,
#title::before {
background-image: url("${url}") !important;
background-image: url("${fullUrl}") !important;
}
/* Icon-only sidebar: wordmark → compact mark (theme-aware). */
body.student.icon-only-sidebar:not(:has(#menu li.hasChildren.active)) #menu > ul::before {
background-image: url("${iconUrl}") !important;
}
`;
}
+29 -60
View File
@@ -34,33 +34,17 @@
function closeEdit() {
sidebarState.setEditMode(false);
menuEl.classList.remove("bsplus-sidebar-edit-mode");
void import("@/seqta/utils/Openers/menuOptionsState").then((mod) => {
mod.setMenuOptionsOpen(false);
});
}
function findFolder(
items: SidebarItemModel[],
key: string,
): SidebarItemModel | null {
for (const item of items) {
if (item.key === key) return item;
if (item.children.length) {
const nested = findFolder(item.children, key);
if (nested) return nested;
}
}
return null;
}
/** Folder meta for each drill frame (icon/label from the live tree). */
const drillFolders = $derived.by(() => {
return sidebarState.drillStack.map((frame) => {
const folder = findFolder(sidebarState.items, frame.key);
return { frame, folder };
});
});
const drillFolders = $derived(
sidebarState.drillStack.map((frame) => ({
frame,
folder: sidebarState.findByKey(frame.key),
})),
);
function onToggleVisible(key: string, visible: boolean) {
sidebarState.setItemVisibility(key, visible);
@@ -105,26 +89,13 @@
};
});
// SEQTA strips `.active` from `#menu li` after clicks — keep it on our custom
// rows so theme animations (palm/sand) and drill `.sub` chrome stay applied.
// SEQTA strips `.active` from `#menu li` after clicks — re-apply from state.
// Do NOT MutationObserver class changes here: restore writes `.active`, SEQTA
// strips it again, and the feedback loop freezes the tab.
$effect(() => {
// Re-run when the route-active key changes so we re-bind the observer target.
void sidebarState.activeKey;
void sidebarState.isDrilling;
const root = document.getElementById("bsplus-sidebar-root");
if (!root) return;
restoreCustomMenuActive();
const observer = new MutationObserver(() => restoreCustomMenuActive());
observer.observe(root, {
subtree: true,
attributes: true,
attributeFilter: ["class"],
});
return () => observer.disconnect();
});
</script>
@@ -151,9 +122,8 @@
{item}
active={sidebarState.activeKey === item.key}
compact={sidebarState.compact}
editMode={sidebarState.editMode}
editMode={true}
visible={itemVisible(item.key)}
drillEnter={false}
{onActivate}
{onToggleVisible}
{onDragStart}
@@ -173,21 +143,19 @@
</button>
</li>
{:else if drillFolders.length}
<!--
Keep all root rows in the DOM (like native SEQTA) so sidebar-animation.scss
can slide sibling labels away when a folder opens.
-->
<!-- Keep root rows in the DOM so sidebar-animation.scss can slide siblings.
Never mark root leaves active while drilling — theme decorations
(Beach palm/sand) would show through the transparent .sub panel. -->
{#each sidebarState.visibleRootItems as item (item.key)}
{#if item.key === drillFolders[0].frame.key}
{@render drillLevel(drillFolders, 0)}
{:else}
<SidebarItem
{item}
active={sidebarState.activeKey === item.key}
active={false}
compact={false}
editMode={false}
visible={itemVisible(item.key)}
drillEnter={false}
visible={true}
{onActivate}
/>
{/if}
@@ -198,13 +166,9 @@
{item}
active={sidebarState.activeKey === item.key}
compact={sidebarState.compact}
editMode={sidebarState.editMode}
visible={itemVisible(item.key)}
drillEnter={false}
editMode={false}
visible={true}
{onActivate}
{onToggleVisible}
{onDragStart}
{onDrop}
/>
{/each}
{/if}
@@ -232,7 +196,15 @@
{/if}
<span class="label">{current.frame.label}</span>
</label>
<div class="sub">
<div
class="sub"
class:bsplus-sub-enter={sidebarState.enterFrameKey === current.frame.key}
onanimationend={(e) => {
if (e.target === e.currentTarget) {
sidebarState.clearEnterFrame(current.frame.key);
}
}}
>
<div class="nav">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
@@ -260,13 +232,12 @@
active={sidebarState.activeKey === item.key}
compact={false}
editMode={false}
visible={itemVisible(item.key)}
drillEnter={true}
visible={true}
drillEnter={sidebarState.enterFrameKey === current.frame.key}
{onActivate}
/>
{/each}
{:else}
<!-- Keep nested siblings for the same slide-away animation. -->
{#each current.frame.items as item (item.key)}
{#if item.key === nextKey}
{@render drillLevel(frames, depth + 1)}
@@ -276,8 +247,7 @@
active={sidebarState.activeKey === item.key}
compact={false}
editMode={false}
visible={itemVisible(item.key)}
drillEnter={false}
visible={true}
{onActivate}
/>
{/if}
@@ -302,7 +272,6 @@
position: relative;
box-sizing: border-box;
color: var(--text-color, #fff);
font-family: Rubik, sans-serif;
scrollbar-width: thin;
z-index: 2;
}
+3 -12
View File
@@ -8,6 +8,7 @@
compact: boolean;
editMode: boolean;
visible: boolean;
/** Play intro fly only when the parent folder first opens. */
drillEnter?: boolean;
onActivate: (item: SidebarItem) => void;
onToggleVisible?: (key: string, visible: boolean) => void;
@@ -51,7 +52,7 @@
aria-label={item.label}
aria-current={active ? "page" : undefined}
draggable={editMode}
in:fly={{ x: drillEnter ? 24 : 0, duration: 180 }}
in:fly={{ x: drillEnter ? 24 : 0, duration: drillEnter ? 180 : 0 }}
ondragstart={() => onDragStart?.(item.key)}
ondragover={(e) => e.preventDefault()}
ondrop={() => onDrop?.(item.key)}
@@ -97,20 +98,15 @@
</li>
<style>
/* Defaults only — theme #menu rules (often !important) win for look.
Keep folder + leaf rows left-aligned (no 85% centred width). */
/* Layout defaults — theme #menu rules (often !important) win for look. */
.bsplus-sidebar-item {
position: relative;
list-style: none;
width: auto;
margin: 2px 6px;
/* Match leaf vertical spacing so folders don't sit lower than neighbours. */
padding: 0;
border-radius: 12px;
color: var(--text-color, #fff);
font-family: Rubik, sans-serif;
font-size: 16px;
font-weight: 700;
cursor: pointer;
display: flex;
align-items: center;
@@ -131,7 +127,6 @@
padding: 12px;
cursor: inherit;
color: inherit;
font: inherit;
}
.bsplus-sidebar-item:hover {
@@ -149,7 +144,6 @@
}
.bsplus-sidebar-item.compact {
width: auto;
justify-content: center;
}
@@ -168,9 +162,6 @@
.bsplus-sidebar-item :global(label > svg) {
flex-shrink: 0;
margin: 0 10px 0 4px;
width: 28px !important;
height: 28px !important;
}
.compact :global(label > svg) {
-2
View File
@@ -3,8 +3,6 @@ export {
mountCustomSidebar,
unmountCustomSidebar,
openCustomSidebarEditor,
closeCustomSidebarEditor,
isCustomSidebarMounted,
} from "./mountCustomSidebar";
export { sidebarState } from "./sidebarState.svelte";
export type { SidebarItem, SidebarDrillFrame } from "./types";
+33 -55
View File
@@ -25,24 +25,24 @@ let earlyPrepareStarted = false;
let catchupTimer: ReturnType<typeof setInterval> | null = null;
let nativeMenuListenerAttached = false;
function onNativeMenuUpdated() {
if (menuEl) sidebarState.syncFromNative(menuEl);
}
const settingsListeners: Array<{
key: keyof SettingsState;
listener: ChangeListener;
}> = [];
function syncFromMenu() {
if (menuEl) sidebarState.syncFromNative(menuEl);
}
function startCatchupSync() {
if (catchupTimer) clearInterval(catchupTimer);
let attempts = 0;
catchupTimer = setInterval(() => {
attempts += 1;
if (menuEl) sidebarState.syncFromNative(menuEl);
syncFromMenu();
// Plugins (Analytics, Overview, icons) inject shortly after first paint.
if (attempts >= 60) {
if (catchupTimer) clearInterval(catchupTimer);
clearInterval(catchupTimer!);
catchupTimer = null;
}
}, 50);
@@ -52,7 +52,7 @@ function scheduleSync() {
if (syncTimer) clearTimeout(syncTimer);
syncTimer = setTimeout(() => {
syncTimer = null;
if (menuEl) sidebarState.syncFromNative(menuEl);
syncFromMenu();
}, 50);
}
@@ -80,22 +80,20 @@ function onHashChange() {
function ensureDefaultMenuOrder(menu: HTMLElement) {
const list = getNativeMenuList(menu);
if (!list) return;
const keys = [...list.children]
.map((node) => (node as HTMLElement).dataset.key)
.filter((key): key is string => !!key);
if (!settingsState.defaultmenuorder?.length) {
const current = settingsState.defaultmenuorder ?? [];
if (!current.length) {
settingsState.defaultmenuorder = keys;
return;
}
for (const key of keys) {
if (!settingsState.defaultmenuorder.includes(key)) {
settingsState.defaultmenuorder = [
...settingsState.defaultmenuorder,
key,
];
}
const missing = keys.filter((key) => !current.includes(key));
if (missing.length) {
settingsState.defaultmenuorder = [...current, ...missing];
}
}
@@ -139,18 +137,6 @@ export function openCustomSidebarEditor() {
return true;
}
export function closeCustomSidebarEditor() {
sidebarState.setEditMode(false);
menuEl?.classList.remove("bsplus-sidebar-edit-mode");
void import("@/seqta/utils/Openers/menuOptionsState").then((mod) => {
mod.setMenuOptionsOpen(false);
});
}
export function isCustomSidebarMounted(): boolean {
return app != null;
}
export async function mountCustomSidebar(): Promise<boolean> {
if (isSeqtaEngageExperience()) return false;
if (!settingsState.onoff) return false;
@@ -159,7 +145,7 @@ export async function mountCustomSidebar(): Promise<boolean> {
if (app && menuEl) {
ensureDefaultMenuOrder(menuEl);
sidebarState.syncSettings();
sidebarState.syncFromNative(menuEl);
syncFromMenu();
startCatchupSync();
clearPendingClass();
return true;
@@ -178,25 +164,19 @@ export async function mountCustomSidebar(): Promise<boolean> {
}
if (!list) return false;
if (app) {
menuEl = menu;
ensureDefaultMenuOrder(menu);
sidebarState.syncFromNative(menu);
sidebarState.syncSettings();
syncFromMenu();
if (app) {
clearPendingClass();
return true;
}
menuEl = menu;
menu.classList.add(MENU_CLASS);
// Remove a stale root from a previous HMR / partial mount.
document.getElementById(ROOT_ID)?.remove();
ensureDefaultMenuOrder(menu);
sidebarState.syncSettings();
sidebarState.syncFromNative(menu);
// Mount as a direct child of `#menu` so root is `#menu > ul#bsplus-sidebar-root`.
app = mount(Sidebar, {
target: menu,
props: { menuEl: menu },
@@ -209,14 +189,21 @@ export async function mountCustomSidebar(): Promise<boolean> {
menuObserver?.disconnect();
menuObserver = new MutationObserver((mutations) => {
const ours = document.getElementById(ROOT_ID);
if (ours && mutations.every((m) => ours.contains(m.target))) return;
// Ignore our list entirely. Also ignore native `class` toggles — SEQTA
// re-adds drill `.active` after we clear it; syncing on that freezes the tab.
const relevant = mutations.some((m) => {
if (ours?.contains(m.target as Node)) return false;
if (m.type === "attributes" && m.attributeName === "class") return false;
return true;
});
if (!relevant) return;
scheduleSync();
});
menuObserver.observe(menu, {
subtree: true,
childList: true,
attributes: true,
attributeFilter: ["class", "style", "data-key", "data-path", "data-colour"],
attributeFilter: ["style", "data-key", "data-path", "data-colour"],
});
if (!hashListenerAttached) {
@@ -225,20 +212,15 @@ export async function mountCustomSidebar(): Promise<boolean> {
}
if (!nativeMenuListenerAttached) {
window.addEventListener("bsplus-native-menu-updated", onNativeMenuUpdated);
window.addEventListener("bsplus-native-menu-updated", syncFromMenu);
nativeMenuListenerAttached = true;
}
clearSettingListeners();
registerSetting("iconOnlySidebar", () => {
sidebarState.syncSettings();
});
registerSetting("menuorder", () => {
if (menuEl) sidebarState.syncFromNative(menuEl);
});
registerSetting("menuitems", () => {
if (menuEl) sidebarState.syncFromNative(menuEl);
});
registerSetting("iconOnlySidebar", () => sidebarState.syncSettings());
const resync = () => syncFromMenu();
registerSetting("menuorder", resync);
registerSetting("menuitems", resync);
startCatchupSync();
clearPendingClass();
@@ -261,10 +243,7 @@ export function unmountCustomSidebar() {
}
if (nativeMenuListenerAttached) {
window.removeEventListener(
"bsplus-native-menu-updated",
onNativeMenuUpdated,
);
window.removeEventListener("bsplus-native-menu-updated", syncFromMenu);
nativeMenuListenerAttached = false;
}
@@ -278,7 +257,6 @@ export function unmountCustomSidebar() {
menuEl = null;
sidebarState.resetDrill();
sidebarState.setEditMode(false);
sidebarState.ready = false;
earlyPrepareStarted = false;
clearPendingClass();
}
+6 -6
View File
@@ -1,12 +1,13 @@
import type { SidebarItem } from "./types";
/** Native SEQTA list only — never the custom Svelte `#bsplus-sidebar-root`. */
export function getNativeMenuList(menu: HTMLElement): HTMLElement | null {
return (
(menu.querySelector(
export function getNativeMenuList(
menu: HTMLElement | null = document.getElementById("menu"),
): HTMLElement | null {
if (!menu) return null;
return menu.querySelector(
":scope > ul:not(#bsplus-sidebar-root)",
) as HTMLElement | null) ?? null
);
) as HTMLElement | null;
}
function readLabelText(label: HTMLElement | null): string {
@@ -60,7 +61,6 @@ function parseEntry(entry: HTMLElement): SidebarItem | null {
label: labelText,
iconHtml: readIconHtml(label),
hasChildren: entry.classList.contains("hasChildren") || children.length > 0,
colour: entry.getAttribute("data-colour"),
itemColour: entry.style.getPropertyValue("--item-colour") || null,
betterseqta: entry.dataset.betterseqta === "true",
children,
+97 -51
View File
@@ -37,50 +37,61 @@ function filterVisible(items: SidebarItem[]): SidebarItem[] {
return items.filter((item) => menuItems[item.key]?.toggle !== false);
}
function ensureActive(el: Element | null | undefined) {
if (el instanceof HTMLElement && !el.classList.contains("active")) {
el.classList.add("active");
}
}
/**
* SEQTA (and some themes) strip `.active` from `#menu li` after navigation.
* Theme decorations (e.g. beach palm/sand) and drill `.sub` chrome all depend
* on that class staying on our custom list re-apply it from known state.
* Theme decorations and drill `.sub` chrome depend on that class on our list.
*
* While drilling, never re-apply route-active on root leaves themes like Beach
* paint palm/sand on `#menu > ul > li:not(.hasChildren).active`, and our `.sub`
* is transparent so those decorations show through over folder contents.
*/
export function restoreCustomMenuActive() {
const root = document.getElementById("bsplus-sidebar-root");
if (!root) return;
// Open drill folders (must keep `.active` for `.sub` layout + theme selectors).
for (const li of root.querySelectorAll("li.hasChildren")) {
if (!(li instanceof HTMLElement)) continue;
if (!li.querySelector(":scope > .sub")) continue;
if (!li.classList.contains("active")) li.classList.add("active");
ensureActive(li);
}
// Route-active row — Svelte sets aria-current, but SEQTA often removes `.active`.
const activeKey = sidebarState.activeKey;
const drilling = sidebarState.isDrilling;
if (drilling) {
if (activeKey) {
const activeLi = root.querySelector(
`li.item[data-key="${CSS.escape(activeKey)}"]`,
ensureActive(
root.querySelector(`.sub li.item[data-key="${CSS.escape(activeKey)}"]`),
);
if (
activeLi instanceof HTMLElement &&
!activeLi.classList.contains("active")
) {
activeLi.classList.add("active");
}
for (const li of root.querySelectorAll(
'.sub li.item[aria-current="page"]',
)) {
ensureActive(li);
}
return;
}
if (activeKey) {
ensureActive(
root.querySelector(`li.item[data-key="${CSS.escape(activeKey)}"]`),
);
}
for (const li of root.querySelectorAll('li.item[aria-current="page"]')) {
if (li instanceof HTMLElement && !li.classList.contains("active")) {
li.classList.add("active");
}
ensureActive(li);
}
}
/** @deprecated Use restoreCustomMenuActive */
export const restoreCustomDrillActive = restoreCustomMenuActive;
/** Clear native drill state so it cannot steal pointer-events from the custom list. */
export function clearNativeDrillActive(menu: HTMLElement) {
const nativeList = getNativeMenuList(menu);
nativeList
getNativeMenuList(menu)
?.querySelectorAll("li.hasChildren.active, section.hasChildren.active")
.forEach((node) => node.classList.remove("active"));
}
@@ -113,6 +124,50 @@ function findItemByKey(
return null;
}
function menuTreeEqual(a: SidebarItem[], b: SidebarItem[]): boolean {
if (a === b) return true;
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
const left = a[i];
const right = b[i];
if (
left.key !== right.key ||
left.path !== right.path ||
left.label !== right.label ||
left.iconHtml !== right.iconHtml ||
left.hasChildren !== right.hasChildren ||
left.itemColour !== right.itemColour ||
left.betterseqta !== right.betterseqta ||
left.id !== right.id ||
!menuTreeEqual(left.children, right.children)
) {
return false;
}
}
return true;
}
function drillStackEqual(
a: SidebarDrillFrame[],
b: SidebarDrillFrame[],
): boolean {
if (a === b) return true;
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
const left = a[i];
const right = b[i];
if (
left.key !== right.key ||
left.label !== right.label ||
left.items.length !== right.items.length ||
left.items.some((item, j) => item.key !== right.items[j]?.key)
) {
return false;
}
}
return true;
}
class SidebarState {
items = $state.raw<SidebarItem[]>([]);
drillStack = $state.raw<SidebarDrillFrame[]>([]);
@@ -120,31 +175,22 @@ class SidebarState {
activePath = $state("");
editMode = $state(false);
iconOnly = $state(false);
ready = $state(false);
/** Frame key whose `.sub` should play the one-shot enter animation. */
enterFrameKey = $state<string | null>(null);
visibleRootItems = $derived(
filterVisible(orderItems(this.items, settingsState.menuorder ?? [])),
);
currentItems = $derived(
this.drillStack.length > 0
? this.drillStack[this.drillStack.length - 1].items
: this.visibleRootItems,
);
currentTitle = $derived(
this.drillStack.length > 0
? this.drillStack[this.drillStack.length - 1].label
: null,
);
isDrilling = $derived(this.drillStack.length > 0);
compact = $derived(this.iconOnly && !this.isDrilling && !this.editMode);
syncFromNative(menu: HTMLElement) {
this.items = parseNativeMenu(menu);
this.ready = this.items.length > 0;
const next = parseNativeMenu(menu);
if (!menuTreeEqual(this.items, next)) {
this.items = next;
}
this.syncActiveFromLocation();
this.pruneDrillStack();
}
@@ -172,8 +218,7 @@ class SidebarState {
const segments = path.split("/").filter(Boolean);
while (segments.length > 1) {
segments.pop();
const parentPath = `/${segments.join("/")}`;
const parent = findItemByPath(this.items, parentPath);
const parent = findItemByPath(this.items, `/${segments.join("/")}`);
if (parent) {
this.activeKey = parent.key;
return;
@@ -197,11 +242,14 @@ class SidebarState {
cursor = children;
}
if (!drillStackEqual(this.drillStack, next)) {
this.drillStack = next;
}
}
openFolder(item: SidebarItem) {
if (!item.hasChildren) return;
this.enterFrameKey = item.key;
this.drillStack = [
...this.drillStack,
{
@@ -212,12 +260,20 @@ class SidebarState {
];
}
clearEnterFrame(key?: string) {
if (key == null || this.enterFrameKey === key) {
this.enterFrameKey = null;
}
}
goBack() {
if (!this.drillStack.length) return;
this.enterFrameKey = null;
this.drillStack = this.drillStack.slice(0, -1);
}
resetDrill() {
this.enterFrameKey = null;
this.drillStack = [];
}
@@ -273,28 +329,18 @@ class SidebarState {
const native = findNativeMenuEntry(menu, item);
if (native) {
// Never strip `.active` from custom `#bsplus-sidebar-root` folders.
// Clear native drill once only — repeating clearNativeDrillActive fights
// SEQTA (it re-adds .active) and used to freeze the tab via menu sync.
clearNativeDrillActive(menu);
native.click();
// SEQTA re-opens native drill and strips `.active` after click — undo both.
clearNativeDrillActive(menu);
restoreCustomMenuActive();
requestAnimationFrame(() => {
clearNativeDrillActive(menu);
restoreCustomMenuActive();
});
setTimeout(() => {
clearNativeDrillActive(menu);
restoreCustomMenuActive();
}, 0);
setTimeout(() => {
clearNativeDrillActive(menu);
restoreCustomMenuActive();
}, 50);
setTimeout(() => {
clearNativeDrillActive(menu);
restoreCustomMenuActive();
}, 100);
// Later pass restores custom `.active` only (no native clear loop).
setTimeout(() => restoreCustomMenuActive(), 50);
return;
}
-1
View File
@@ -6,7 +6,6 @@ export type SidebarItem = {
/** Outer HTML of the label's leading SVG (if any). */
iconHtml: string;
hasChildren: boolean;
colour: string | null;
itemColour: string | null;
betterseqta: boolean;
children: SidebarItem[];