277 lines
7.6 KiB
TypeScript
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,
|
|
);
|
|
}
|
|
}
|