rspace-online/shared/tab-cache.ts

277 lines
7.6 KiB
TypeScript

/**
* Client-side tab cache for instant tab switching.
*
* Instead of full page navigations, previously loaded tabs are kept in the DOM
* and shown/hidden via CSS. New tabs are fetched via fetch() + DOMParser on
* first visit, then stay in the DOM forever.
*
* Hiding preserves ALL component state: Shadow DOM, event listeners,
* WebSocket connections, timers.
*/
export class TabCache {
private currentModuleId: string;
private spaceSlug: string;
private panes = new Map<string, HTMLElement>();
constructor(spaceSlug: string, moduleId: string) {
this.spaceSlug = spaceSlug;
this.currentModuleId = moduleId;
}
/** Wrap the initial #app content in a pane div and set up popstate */
init(): boolean {
const app = document.getElementById("app");
if (!app) return false;
// Wrap existing content in a pane
const pane = document.createElement("div");
pane.className = "rspace-tab-pane rspace-tab-pane--active";
pane.dataset.moduleId = this.currentModuleId;
pane.dataset.pageTitle = document.title;
while (app.firstChild) {
pane.appendChild(app.firstChild);
}
app.appendChild(pane);
this.panes.set(this.currentModuleId, pane);
// Push initial state into history
history.replaceState(
{ moduleId: this.currentModuleId, spaceSlug: this.spaceSlug },
"",
window.location.href,
);
// Handle browser back/forward
window.addEventListener("popstate", (e) => {
const state = e.state;
if (state?.moduleId && this.panes.has(state.moduleId)) {
this.showPane(state.moduleId);
} else {
window.location.reload();
}
});
return true;
}
/** Switch to a module tab. Returns true if handled client-side. */
async switchTo(moduleId: string): Promise<boolean> {
if (moduleId === this.currentModuleId) return true;
if (this.panes.has(moduleId)) {
this.showPane(moduleId);
this.updateUrl(moduleId);
return true;
}
return this.fetchAndInject(moduleId);
}
/** Check if a pane exists in the DOM cache */
has(moduleId: string): boolean {
return this.panes.has(moduleId);
}
/** Remove a cached pane from the DOM */
removePane(moduleId: string): void {
const pane = this.panes.get(moduleId);
if (pane) {
pane.remove();
this.panes.delete(moduleId);
}
}
/** Fetch a module page, extract content, and inject into a new pane */
private async fetchAndInject(moduleId: string): Promise<boolean> {
const navUrl = (window as any).__rspaceNavUrl;
if (!navUrl) return false;
const url: string = navUrl(this.spaceSlug, moduleId);
// Cross-origin URLs → fall back to full navigation
try {
const resolved = new URL(url, window.location.href);
if (resolved.origin !== window.location.origin) return false;
} catch {
return false;
}
const app = document.getElementById("app");
if (!app) return false;
// Show loading spinner
const loadingPane = document.createElement("div");
loadingPane.className =
"rspace-tab-pane rspace-tab-pane--active rspace-tab-loading";
loadingPane.innerHTML = '<div class="rspace-tab-spinner"></div>';
this.hideAllPanes();
app.appendChild(loadingPane);
try {
const resp = await fetch(url);
if (!resp.ok) {
loadingPane.remove();
return false;
}
const html = await resp.text();
const content = this.extractContent(html);
if (!content) {
loadingPane.remove();
return false;
}
// Remove loading spinner
loadingPane.remove();
// Create the real pane
const pane = document.createElement("div");
pane.className = "rspace-tab-pane rspace-tab-pane--active";
pane.dataset.moduleId = moduleId;
pane.dataset.pageTitle = content.title;
pane.innerHTML = content.body;
app.appendChild(pane);
this.panes.set(moduleId, pane);
// Load module-specific assets
this.loadAssets(content.scripts, content.styles);
// Update shell state
this.currentModuleId = moduleId;
document.title = content.title;
this.updateShellState(moduleId);
this.updateUrl(moduleId);
this.updateCanvasLayout(moduleId);
return true;
} catch {
loadingPane.remove();
return false;
}
}
/** Parse full HTML response and extract module content */
private extractContent(
html: string,
): {
body: string;
title: string;
scripts: string[];
styles: string[];
} | null {
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
// Extract #app content or .rspace-iframe-wrap (for external app modules)
const appEl = doc.getElementById("app");
const iframeWrap = doc.querySelector(".rspace-iframe-wrap");
let body: string;
if (appEl) {
body = appEl.innerHTML;
} else if (iframeWrap) {
body = iframeWrap.outerHTML;
} else {
return null;
}
const title = doc.title || "";
// Extract module-specific scripts (exclude shell.js and inline shell bootstrap)
const scripts: string[] = [];
doc.querySelectorAll('script[type="module"]').forEach((s) => {
const src = s.getAttribute("src") || "";
if (src.includes("shell.js")) return;
const text = s.textContent || "";
if (text.includes("import '/shell.js")) return;
if (text.includes('import "/shell.js')) return;
if (src) scripts.push(src);
});
// Extract module-specific styles (exclude shell.css)
const styles: string[] = [];
doc.querySelectorAll('link[rel="stylesheet"]').forEach((l) => {
const href = l.getAttribute("href") || "";
if (href.includes("shell.css")) return;
styles.push(href);
});
return { body, title, scripts, styles };
}
/** Load script and style assets (idempotent — browsers deduplicate module scripts) */
private loadAssets(scripts: string[], styles: string[]): void {
for (const src of scripts) {
const el = document.createElement("script");
el.type = "module";
el.src = src;
document.body.appendChild(el);
}
for (const href of styles) {
if (document.querySelector(`link[href="${CSS.escape(href)}"]`))
continue;
const el = document.createElement("link");
el.rel = "stylesheet";
el.href = href;
document.head.appendChild(el);
}
}
/** Show a specific pane and update shell state */
private showPane(moduleId: string): void {
this.hideAllPanes();
const pane = this.panes.get(moduleId);
if (pane) {
pane.classList.add("rspace-tab-pane--active");
const storedTitle = pane.dataset.pageTitle;
if (storedTitle) document.title = storedTitle;
}
this.currentModuleId = moduleId;
this.updateShellState(moduleId);
this.updateCanvasLayout(moduleId);
}
/** Hide all panes */
private hideAllPanes(): void {
const app = document.getElementById("app");
if (!app) return;
app.querySelectorAll(".rspace-tab-pane").forEach((p) => {
p.classList.remove("rspace-tab-pane--active");
});
}
/** Update app-switcher and tab-bar to reflect current module */
private updateShellState(moduleId: string): void {
const appSwitcher = document.querySelector("rstack-app-switcher");
if (appSwitcher) appSwitcher.setAttribute("current", moduleId);
const tabBar = (window as any).__rspaceTabBar;
if (tabBar) tabBar.setAttribute("active", "layer-" + moduleId);
}
/** Toggle canvas-layout class on #app */
private updateCanvasLayout(moduleId: string): void {
const app = document.getElementById("app");
if (!app) return;
if (moduleId === "canvas") {
app.classList.add("canvas-layout");
} else {
app.classList.remove("canvas-layout");
}
}
/** Push new URL to browser history */
private updateUrl(moduleId: string): void {
const navUrl = (window as any).__rspaceNavUrl;
if (!navUrl) return;
const url: string = navUrl(this.spaceSlug, moduleId);
history.pushState(
{ moduleId, spaceSlug: this.spaceSlug },
"",
url,
);
}
}