397 lines
12 KiB
TypeScript
397 lines
12 KiB
TypeScript
/**
|
|
* Client-side tab cache for instant tab and space 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.
|
|
*
|
|
* Pane keys are `${spaceSlug}:${moduleId}` so panes from different spaces
|
|
* coexist in the DOM. On space switch, current space's panes are hidden and
|
|
* the target space's panes are shown (or fetched on first visit).
|
|
*
|
|
* 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;
|
|
}
|
|
|
|
/** Current space slug */
|
|
get currentSpace(): string {
|
|
return this.spaceSlug;
|
|
}
|
|
|
|
/** Current module ID */
|
|
get currentModule(): string {
|
|
return this.currentModuleId;
|
|
}
|
|
|
|
/** Build the composite pane key */
|
|
private paneKey(space: string, moduleId: string): string {
|
|
return `${space}:${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;
|
|
|
|
const key = this.paneKey(this.spaceSlug, this.currentModuleId);
|
|
|
|
// 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.spaceSlug = this.spaceSlug;
|
|
pane.dataset.pageTitle = document.title;
|
|
|
|
while (app.firstChild) {
|
|
pane.appendChild(app.firstChild);
|
|
}
|
|
app.appendChild(pane);
|
|
this.panes.set(key, 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?.dashboard) {
|
|
// Dashboard state — hide all panes and show dashboard
|
|
this.hideAllPanes();
|
|
const dashboard = document.querySelector("rstack-user-dashboard");
|
|
if (dashboard) (dashboard as HTMLElement).style.display = "";
|
|
return;
|
|
}
|
|
if (!state?.moduleId) {
|
|
window.location.reload();
|
|
return;
|
|
}
|
|
// If returning from dashboard, hide it
|
|
const dashboard = document.querySelector("rstack-user-dashboard");
|
|
if (dashboard) (dashboard as HTMLElement).style.display = "none";
|
|
const stateSpace = state.spaceSlug || this.spaceSlug;
|
|
const key = this.paneKey(stateSpace, state.moduleId);
|
|
if (this.panes.has(key)) {
|
|
if (stateSpace !== this.spaceSlug) {
|
|
this.hideAllPanes();
|
|
this.spaceSlug = stateSpace;
|
|
}
|
|
this.showPane(stateSpace, state.moduleId);
|
|
} else {
|
|
window.location.reload();
|
|
}
|
|
});
|
|
|
|
return true;
|
|
}
|
|
|
|
/** Switch to a module tab within the current space. Returns true if handled client-side. */
|
|
async switchTo(moduleId: string): Promise<boolean> {
|
|
const key = this.paneKey(this.spaceSlug, moduleId);
|
|
if (moduleId === this.currentModuleId && this.panes.has(key)) return true;
|
|
|
|
if (this.panes.has(key)) {
|
|
this.showPane(this.spaceSlug, moduleId);
|
|
this.updateUrl(this.spaceSlug, moduleId);
|
|
return true;
|
|
}
|
|
|
|
return this.fetchAndInject(this.spaceSlug, moduleId);
|
|
}
|
|
|
|
/**
|
|
* Switch to a different space, loading a module within it.
|
|
* Hides all panes from the current space, shows/fetches the target space's pane.
|
|
* Returns true if handled client-side.
|
|
*/
|
|
async switchSpace(newSpace: string, moduleId: string): Promise<boolean> {
|
|
if (newSpace === this.spaceSlug && moduleId === this.currentModuleId) return true;
|
|
|
|
const key = this.paneKey(newSpace, moduleId);
|
|
|
|
// Hide all current panes
|
|
this.hideAllPanes();
|
|
|
|
// Update active space
|
|
const oldSpace = this.spaceSlug;
|
|
this.spaceSlug = newSpace;
|
|
|
|
if (this.panes.has(key)) {
|
|
this.showPane(newSpace, moduleId);
|
|
this.updateUrl(newSpace, moduleId);
|
|
this.updateSpaceChrome(newSpace);
|
|
return true;
|
|
}
|
|
|
|
const ok = await this.fetchAndInject(newSpace, moduleId);
|
|
if (ok) {
|
|
this.updateSpaceChrome(newSpace);
|
|
return true;
|
|
}
|
|
|
|
// Rollback on failure
|
|
this.spaceSlug = oldSpace;
|
|
return false;
|
|
}
|
|
|
|
/** Check if a pane exists in the DOM cache */
|
|
has(moduleId: string): boolean {
|
|
return this.panes.has(this.paneKey(this.spaceSlug, moduleId));
|
|
}
|
|
|
|
/** Remove a cached pane from the DOM */
|
|
removePane(moduleId: string): void {
|
|
const key = this.paneKey(this.spaceSlug, moduleId);
|
|
const pane = this.panes.get(key);
|
|
if (pane) {
|
|
pane.remove();
|
|
this.panes.delete(key);
|
|
}
|
|
}
|
|
|
|
/** Fetch a module page, extract content, and inject into a new pane */
|
|
private async fetchAndInject(space: string, moduleId: string): Promise<boolean> {
|
|
const navUrl = (window as any).__rspaceNavUrl;
|
|
if (!navUrl) return false;
|
|
const url: string = navUrl(space, moduleId);
|
|
|
|
// For cross-space fetches, use the path-based API to stay same-origin
|
|
let fetchUrl = url;
|
|
try {
|
|
const resolved = new URL(url, window.location.href);
|
|
if (resolved.origin !== window.location.origin) {
|
|
// Rewrite subdomain URL to path format: /{space}/{moduleId}
|
|
fetchUrl = `/${space}/${moduleId}`;
|
|
}
|
|
} 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(fetchUrl);
|
|
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();
|
|
|
|
const key = this.paneKey(space, moduleId);
|
|
|
|
// Create the real pane
|
|
const pane = document.createElement("div");
|
|
pane.className = "rspace-tab-pane rspace-tab-pane--active";
|
|
pane.dataset.moduleId = moduleId;
|
|
pane.dataset.spaceSlug = space;
|
|
pane.dataset.pageTitle = content.title;
|
|
pane.innerHTML = content.body;
|
|
app.appendChild(pane);
|
|
this.panes.set(key, pane);
|
|
|
|
// Load module-specific assets
|
|
this.loadAssets(content.scripts, content.styles, content.inlineStyles, `${space}-${moduleId}`);
|
|
|
|
// Update shell state
|
|
this.currentModuleId = moduleId;
|
|
document.title = content.title;
|
|
this.updateShellState(moduleId);
|
|
this.updateUrl(space, 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[];
|
|
inlineStyles: 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);
|
|
});
|
|
|
|
// Extract inline <style> blocks (exclude shell-embedded styles)
|
|
const inlineStyles: string[] = [];
|
|
doc.querySelectorAll("head style").forEach((s) => {
|
|
const css = s.textContent || "";
|
|
// Skip the shell's own embedded styles (tab-pane, spinner, etc.)
|
|
if (css.includes(".rspace-tab-pane")) return;
|
|
if (css.trim()) inlineStyles.push(css);
|
|
});
|
|
|
|
return { body, title, scripts, styles, inlineStyles };
|
|
}
|
|
|
|
/** Load script and style assets (idempotent — browsers deduplicate module scripts) */
|
|
private loadAssets(scripts: string[], styles: string[], inlineStyles: string[] = [], moduleId?: 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);
|
|
}
|
|
|
|
// Inject inline <style> blocks (tagged for deduplication)
|
|
for (let i = 0; i < inlineStyles.length; i++) {
|
|
const tag = `tab-style-${moduleId || "unknown"}-${i}`;
|
|
if (document.querySelector(`style[data-tab-style="${tag}"]`)) continue;
|
|
const el = document.createElement("style");
|
|
el.dataset.tabStyle = tag;
|
|
el.textContent = inlineStyles[i];
|
|
document.head.appendChild(el);
|
|
}
|
|
}
|
|
|
|
/** Show a specific pane and update shell state */
|
|
private showPane(space: string, moduleId: string): void {
|
|
this.hideAllPanes();
|
|
const key = this.paneKey(space, moduleId);
|
|
const pane = this.panes.get(key);
|
|
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 (public so the shell can call it for dashboard) */
|
|
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 === "rspace") {
|
|
app.classList.add("canvas-layout");
|
|
} else {
|
|
app.classList.remove("canvas-layout");
|
|
}
|
|
}
|
|
|
|
/** Update space-specific shell chrome (space switcher, body attrs) */
|
|
private updateSpaceChrome(newSpace: string): void {
|
|
// Update space-switcher attribute
|
|
const spaceSwitcher = document.querySelector("rstack-space-switcher");
|
|
if (spaceSwitcher) {
|
|
spaceSwitcher.setAttribute("current", newSpace);
|
|
spaceSwitcher.setAttribute("name", newSpace);
|
|
}
|
|
|
|
// Update body data attribute
|
|
document.body?.setAttribute("data-space-slug", newSpace);
|
|
}
|
|
|
|
/** Push new URL to browser history */
|
|
private updateUrl(space: string, moduleId: string): void {
|
|
const navUrl = (window as any).__rspaceNavUrl;
|
|
if (!navUrl) return;
|
|
const url: string = navUrl(space, moduleId);
|
|
history.pushState(
|
|
{ moduleId, spaceSlug: space },
|
|
"",
|
|
url,
|
|
);
|
|
}
|
|
}
|