Files
BetterSEQTA-Plus/src/seqta/ui/sidebar/mountCustomSidebar.ts
T
2026-08-19 17:44:53 +09:30

380 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { mount, unmount } from "svelte";
import type { SettingsState } from "@/types/storage";
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
import { waitForElm } from "@/seqta/utils/waitForElm";
import { waitForSeqtaMenu } from "@/seqta/utils/waitForSeqtaShell";
import Sidebar from "./Sidebar.svelte";
import { getNativeMenuList } from "./parseNativeMenu";
import {
clearNativeDrillActive,
sidebarState,
} from "./sidebarState.svelte";
import {
applySidebarLook,
applySidebarStyleClass,
clearSidebarAppearance,
} from "./sidebarStyles";
const ROOT_ID = "bsplus-sidebar-root";
const MENU_CLASS = "bsplus-custom-sidebar";
const PENDING_CLASS = "bsplus-custom-sidebar-pending";
type ChangeListener = (newValue: unknown, oldValue: unknown) => void;
let app: ReturnType<typeof mount> | null = null;
let menuEl: HTMLElement | null = null;
let menuObserver: MutationObserver | null = null;
let syncTimer: ReturnType<typeof setTimeout> | null = null;
let hashListenerAttached = false;
let sidebarCaptureAttached = false;
let earlyPrepareStarted = false;
let catchupTimers: ReturnType<typeof setTimeout>[] = [];
let nativeMenuListenerAttached = false;
let lastMenuFingerprint = "";
const settingsListeners: Array<{
key: keyof SettingsState;
listener: ChangeListener;
}> = [];
/** Stable fingerprint of native menu keys so unchanged DOM does not re-sync. */
function menuFingerprint(menu: HTMLElement): string {
const list = getNativeMenuList(menu);
if (!list) return "";
const keys: string[] = [];
for (const node of list.children) {
const el = node as HTMLElement;
if (el.id === ROOT_ID) continue;
const key = el.dataset.key ?? "";
const path = el.dataset.path ?? "";
const colour = el.dataset.colour ?? "";
keys.push(`${key}|${path}|${colour}`);
}
return `${keys.length}:${keys.join(",")}`;
}
function syncFromMenu(force = false) {
if (!menuEl) return;
const next = menuFingerprint(menuEl);
if (!force && next === lastMenuFingerprint) return;
lastMenuFingerprint = next;
sidebarState.syncFromNative(menuEl);
}
/**
* 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() {
if (syncTimer) clearTimeout(syncTimer);
syncTimer = setTimeout(() => {
syncTimer = null;
syncFromMenu();
}, 50);
}
/**
* Capture-phase: own all clicks inside the custom list so SEQTA's #menu handlers
* never see them. Opening Goals/Folios via SEQTA + our drill UI freezes the tab.
*/
function onCustomSidebarCaptureClick(event: MouseEvent) {
if (!menuEl || sidebarState.editMode) return;
const root = document.getElementById(ROOT_ID);
const target = event.target;
if (!(target instanceof Element) || !root?.contains(target)) return;
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
const back = target.closest(".back");
if (back instanceof HTMLElement && root.contains(back)) {
sidebarState.goBack();
return;
}
const li = target.closest("li.item[data-key]");
if (!(li instanceof HTMLElement) || !root.contains(li)) return;
// Already-open folder chrome (renders its own `.sub`) — ignore; use Back.
if (
li.classList.contains("hasChildren") &&
li.querySelector(":scope > .sub")
) {
return;
}
const key = li.dataset.key;
const path = li.dataset.path;
const item = sidebarState.resolveItem(key, path);
if (item) sidebarState.activateItem(item, menuEl);
}
function onHashChange() {
sidebarState.syncActiveFromLocation();
if (menuEl) clearNativeDrillActive(menuEl);
if (!sidebarState.isDrilling) return;
const path = sidebarState.activePath;
if (!path) return;
const openKey = sidebarState.drillStack[0]?.key;
const top = sidebarState.visibleRootItems.find(
(item) =>
item.key === openKey ||
item.path === path ||
(item.path != null && path.startsWith(`${item.path}/`)),
);
if (openKey && top?.key === openKey) return;
if (!path.includes("/")) sidebarState.resetDrill();
}
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);
const current = settingsState.defaultmenuorder ?? [];
if (!current.length) {
settingsState.defaultmenuorder = keys;
return;
}
const missing = keys.filter((key) => !current.includes(key));
if (missing.length) {
settingsState.defaultmenuorder = [...current, ...missing];
}
}
function registerSetting(
key: keyof SettingsState,
listener: ChangeListener,
) {
settingsState.register(key, listener);
settingsListeners.push({ key, listener });
}
function clearSettingListeners() {
for (const { key, listener } of settingsListeners) {
settingsState.unregister(key, listener);
}
settingsListeners.length = 0;
}
function clearPendingClass() {
document.documentElement.classList.remove(PENDING_CLASS);
}
/**
* Hide the native SEQTA menu immediately (even under the loading overlay)
* and begin mounting the Svelte sidebar as soon as `#menu` exists.
*/
export function prepareCustomSidebarEarly() {
if (isSeqtaEngageExperience()) return;
if (!settingsState.onoff) return;
if (earlyPrepareStarted) return;
earlyPrepareStarted = true;
document.documentElement.classList.add(PENDING_CLASS);
// Width/blur vars before mount so layout doesn't jump.
applySidebarLook(document.getElementById("menu"));
void mountCustomSidebar();
}
export function openCustomSidebarEditor() {
if (!app || !menuEl) return false;
sidebarState.setEditMode(true);
menuEl.classList.add("bsplus-sidebar-edit-mode");
return true;
}
export async function mountCustomSidebar(): Promise<boolean> {
if (isSeqtaEngageExperience()) return false;
if (!settingsState.onoff) return false;
// Already mounted — re-sync after Home/News/Analytics injections.
if (app && menuEl) {
ensureDefaultMenuOrder(menuEl);
applySidebarStyleClass(menuEl, settingsState.sidebarStyle);
applySidebarLook(menuEl);
sidebarState.syncSettings();
syncFromMenu();
startCatchupSync();
clearPendingClass();
return true;
}
document.documentElement.classList.add(PENDING_CLASS);
let menu: HTMLElement | null = null;
try {
menu = (await waitForSeqtaMenu(50, 200)) as HTMLElement;
} catch {
return false;
}
if (!menu) return false;
// Prefer a populated native list, but don't block forever during loading.
let list = getNativeMenuList(menu);
if (!list) {
await waitForElm("#menu > ul:not(#bsplus-sidebar-root)", true, 50, 120);
list = getNativeMenuList(menu);
}
if (!list) return false;
menuEl = menu;
ensureDefaultMenuOrder(menu);
sidebarState.syncSettings();
syncFromMenu();
if (app) {
clearPendingClass();
return true;
}
menu.classList.add(MENU_CLASS);
applySidebarStyleClass(menu, settingsState.sidebarStyle);
applySidebarLook(menu);
document.getElementById(ROOT_ID)?.remove();
app = mount(Sidebar, {
target: menu,
props: { menuEl: menu },
});
menu
.querySelectorAll(".bsplus-sidebar-offscreen")
.forEach((node) => node.classList.remove("bsplus-sidebar-offscreen"));
menuObserver?.disconnect();
menuObserver = new MutationObserver((mutations) => {
const ours = document.getElementById(ROOT_ID);
// Ignore our list entirely. Ignore native class/style churn — SEQTA and
// theme transitions rewrite those constantly; syncing on them freezes the tab.
const relevant = mutations.some((m) => {
if (ours?.contains(m.target as Node)) return false;
if (m.type === "attributes") {
const attr = m.attributeName;
if (attr === "class" || attr === "style") return false;
}
return true;
});
if (!relevant) return;
scheduleSync();
});
menuObserver.observe(menu, {
subtree: true,
childList: true,
attributes: true,
attributeFilter: ["data-key", "data-path", "data-colour"],
});
if (!sidebarCaptureAttached) {
document.addEventListener("click", onCustomSidebarCaptureClick, true);
sidebarCaptureAttached = true;
}
if (!hashListenerAttached) {
window.addEventListener("hashchange", onHashChange);
hashListenerAttached = true;
}
if (!nativeMenuListenerAttached) {
window.addEventListener("bsplus-native-menu-updated", onNativeMenuUpdated);
nativeMenuListenerAttached = true;
}
clearSettingListeners();
registerSetting("iconOnlySidebar", () => sidebarState.syncSettings());
registerSetting("sidebarStyle", () =>
applySidebarStyleClass(menuEl, settingsState.sidebarStyle),
);
for (const key of [
"sidebarDensity",
"sidebarCornerRadius",
"sidebarActiveIndicator",
"sidebarWidth",
"sidebarBlur",
] as const) {
registerSetting(key, () => applySidebarLook(menuEl));
}
const resync = () => syncFromMenu(true);
registerSetting("menuorder", resync);
registerSetting("menuitems", resync);
startCatchupSync();
clearPendingClass();
return true;
}
export function unmountCustomSidebar() {
menuObserver?.disconnect();
menuObserver = null;
if (syncTimer) clearTimeout(syncTimer);
syncTimer = null;
for (const t of catchupTimers) clearTimeout(t);
catchupTimers = [];
lastMenuFingerprint = "";
clearSettingListeners();
if (hashListenerAttached) {
window.removeEventListener("hashchange", onHashChange);
hashListenerAttached = false;
}
if (sidebarCaptureAttached) {
document.removeEventListener("click", onCustomSidebarCaptureClick, true);
sidebarCaptureAttached = false;
}
if (nativeMenuListenerAttached) {
window.removeEventListener("bsplus-native-menu-updated", onNativeMenuUpdated);
nativeMenuListenerAttached = false;
}
if (app) {
unmount(app);
app = null;
}
document.getElementById(ROOT_ID)?.remove();
if (menuEl) {
menuEl.classList.remove(MENU_CLASS, "bsplus-sidebar-edit-mode");
clearSidebarAppearance(menuEl);
}
menuEl = null;
sidebarState.resetDrill();
sidebarState.setEditMode(false);
earlyPrepareStarted = false;
clearPendingClass();
}