mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-08-28 09:11:06 +00:00
feat: sidebar cleanup + fixes
This commit is contained in:
+30
-27
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -3,8 +3,6 @@ export {
|
||||
mountCustomSidebar,
|
||||
unmountCustomSidebar,
|
||||
openCustomSidebarEditor,
|
||||
closeCustomSidebarEditor,
|
||||
isCustomSidebarMounted,
|
||||
} from "./mountCustomSidebar";
|
||||
export { sidebarState } from "./sidebarState.svelte";
|
||||
export type { SidebarItem, SidebarDrillFrame } from "./types";
|
||||
|
||||
@@ -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;
|
||||
|
||||
menuEl = menu;
|
||||
ensureDefaultMenuOrder(menu);
|
||||
sidebarState.syncSettings();
|
||||
syncFromMenu();
|
||||
|
||||
if (app) {
|
||||
menuEl = menu;
|
||||
ensureDefaultMenuOrder(menu);
|
||||
sidebarState.syncFromNative(menu);
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
":scope > ul:not(#bsplus-sidebar-root)",
|
||||
) as HTMLElement | null) ?? null
|
||||
);
|
||||
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;
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@@ -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;
|
||||
if (activeKey) {
|
||||
const activeLi = root.querySelector(
|
||||
`li.item[data-key="${CSS.escape(activeKey)}"]`,
|
||||
);
|
||||
if (
|
||||
activeLi instanceof HTMLElement &&
|
||||
!activeLi.classList.contains("active")
|
||||
) {
|
||||
activeLi.classList.add("active");
|
||||
const drilling = sidebarState.isDrilling;
|
||||
|
||||
if (drilling) {
|
||||
if (activeKey) {
|
||||
ensureActive(
|
||||
root.querySelector(`.sub li.item[data-key="${CSS.escape(activeKey)}"]`),
|
||||
);
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
this.drillStack = next;
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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[];
|
||||
|
||||
Reference in New Issue
Block a user