feat: folk-rapp postMessage bridge, module switcher, open-in-tab
Enhance the folk-rapp canvas shape with three improvements: 1. PostMessage bridge: parent sends context to iframe on load, listens for shape-updated events from CommunitySync. Green status dot indicates active connection. 2. Module switcher: header dropdown (⇄ button) lets users change which rApp is embedded without recreating the shape. 3. Open-in-tab: ↗ button navigates to the module page (adds a tab) instead of opening a new browser window. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
0a795006d3
commit
c934ed3fd0
199
lib/folk-rapp.ts
199
lib/folk-rapp.ts
|
|
@ -8,6 +8,11 @@ import { rspaceNavUrl } from "../shared/url-helpers";
|
||||||
* Unlike folk-embed (generic URL iframe), folk-rapp understands the module
|
* Unlike folk-embed (generic URL iframe), folk-rapp understands the module
|
||||||
* system: it stores moduleId + spaceSlug, derives the iframe URL, shows
|
* system: it stores moduleId + spaceSlug, derives the iframe URL, shows
|
||||||
* the module's icon/badge in the header, and can switch modules in-place.
|
* the module's icon/badge in the header, and can switch modules in-place.
|
||||||
|
*
|
||||||
|
* PostMessage protocol:
|
||||||
|
* Parent → iframe: { source: "rspace-parent", type: "context", shapeId, space, moduleId }
|
||||||
|
* iframe → parent: { source: "rspace-canvas", type: "shape-updated", ... } (from CommunitySync)
|
||||||
|
* iframe → parent: { source: "rspace-rapp", type: "navigate", moduleId }
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Module metadata for header display (subset of rstack-app-switcher badges)
|
// Module metadata for header display (subset of rstack-app-switcher badges)
|
||||||
|
|
@ -64,6 +69,7 @@ const styles = css`
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 7px;
|
gap: 7px;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rapp-badge {
|
.rapp-badge {
|
||||||
|
|
@ -211,6 +217,79 @@ const styles = css`
|
||||||
color: #0f172a;
|
color: #0f172a;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Module switcher dropdown */
|
||||||
|
.rapp-switcher {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
margin-top: 4px;
|
||||||
|
min-width: 180px;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: #1e293b;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
|
||||||
|
padding: 4px;
|
||||||
|
z-index: 100;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rapp-switcher.open {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rapp-switcher-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 5px 8px;
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-size: 12px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
transition: background 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rapp-switcher-item:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rapp-switcher-item.active {
|
||||||
|
background: rgba(6, 182, 212, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rapp-switcher-badge {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.45rem;
|
||||||
|
font-weight: 900;
|
||||||
|
color: #0f172a;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status indicator for postMessage connection */
|
||||||
|
.rapp-status {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #475569;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: background 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rapp-status.connected {
|
||||||
|
background: #22c55e;
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
|
@ -238,9 +317,12 @@ export class FolkRApp extends FolkShape {
|
||||||
#spaceSlug: string = "";
|
#spaceSlug: string = "";
|
||||||
#iframe: HTMLIFrameElement | null = null;
|
#iframe: HTMLIFrameElement | null = null;
|
||||||
#contentEl: HTMLElement | null = null;
|
#contentEl: HTMLElement | null = null;
|
||||||
|
#messageHandler: ((e: MessageEvent) => void) | null = null;
|
||||||
|
#statusEl: HTMLElement | null = null;
|
||||||
|
|
||||||
get moduleId() { return this.#moduleId; }
|
get moduleId() { return this.#moduleId; }
|
||||||
set moduleId(value: string) {
|
set moduleId(value: string) {
|
||||||
|
if (this.#moduleId === value) return;
|
||||||
this.#moduleId = value;
|
this.#moduleId = value;
|
||||||
this.requestUpdate("moduleId");
|
this.requestUpdate("moduleId");
|
||||||
this.dispatchEvent(new CustomEvent("content-change"));
|
this.dispatchEvent(new CustomEvent("content-change"));
|
||||||
|
|
@ -249,6 +331,7 @@ export class FolkRApp extends FolkShape {
|
||||||
|
|
||||||
get spaceSlug() { return this.#spaceSlug; }
|
get spaceSlug() { return this.#spaceSlug; }
|
||||||
set spaceSlug(value: string) {
|
set spaceSlug(value: string) {
|
||||||
|
if (this.#spaceSlug === value) return;
|
||||||
this.#spaceSlug = value;
|
this.#spaceSlug = value;
|
||||||
this.requestUpdate("spaceSlug");
|
this.requestUpdate("spaceSlug");
|
||||||
this.dispatchEvent(new CustomEvent("content-change"));
|
this.dispatchEvent(new CustomEvent("content-change"));
|
||||||
|
|
@ -274,10 +357,13 @@ export class FolkRApp extends FolkShape {
|
||||||
<span class="rapp-badge">${headerBadge}</span>
|
<span class="rapp-badge">${headerBadge}</span>
|
||||||
<span class="rapp-name">${headerName}</span>
|
<span class="rapp-name">${headerName}</span>
|
||||||
<span class="rapp-icon">${headerIcon}</span>
|
<span class="rapp-icon">${headerIcon}</span>
|
||||||
|
<span class="rapp-status" title="Not connected"></span>
|
||||||
|
<div class="rapp-switcher"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="rapp-actions">
|
<div class="rapp-actions">
|
||||||
|
<button class="switch-btn" title="Switch module">⇄</button>
|
||||||
<button class="open-tab-btn" title="Open in tab">↗</button>
|
<button class="open-tab-btn" title="Open in tab">↗</button>
|
||||||
<button class="close-btn" title="Close">\u00D7</button>
|
<button class="close-btn" title="Close">×</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="rapp-content"></div>
|
<div class="rapp-content"></div>
|
||||||
|
|
@ -290,14 +376,28 @@ export class FolkRApp extends FolkShape {
|
||||||
}
|
}
|
||||||
|
|
||||||
this.#contentEl = wrapper.querySelector(".rapp-content") as HTMLElement;
|
this.#contentEl = wrapper.querySelector(".rapp-content") as HTMLElement;
|
||||||
|
this.#statusEl = wrapper.querySelector(".rapp-status") as HTMLElement;
|
||||||
|
const switchBtn = wrapper.querySelector(".switch-btn") as HTMLButtonElement;
|
||||||
const openTabBtn = wrapper.querySelector(".open-tab-btn") as HTMLButtonElement;
|
const openTabBtn = wrapper.querySelector(".open-tab-btn") as HTMLButtonElement;
|
||||||
const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement;
|
const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement;
|
||||||
|
const switcherEl = wrapper.querySelector(".rapp-switcher") as HTMLElement;
|
||||||
|
|
||||||
// Open in tab navigates to the module's page
|
// Module switcher dropdown
|
||||||
|
this.#buildSwitcher(switcherEl);
|
||||||
|
switchBtn.addEventListener("click", (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
switcherEl.classList.toggle("open");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close switcher when clicking elsewhere
|
||||||
|
const closeSwitcher = () => switcherEl.classList.remove("open");
|
||||||
|
root.addEventListener("click", closeSwitcher);
|
||||||
|
|
||||||
|
// Open in tab — navigate to the module's page via tab bar
|
||||||
openTabBtn.addEventListener("click", (e) => {
|
openTabBtn.addEventListener("click", (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (this.#moduleId && this.#spaceSlug) {
|
if (this.#moduleId && this.#spaceSlug) {
|
||||||
window.open(rspaceNavUrl(this.#spaceSlug, this.#moduleId), "_blank");
|
window.location.href = rspaceNavUrl(this.#spaceSlug, this.#moduleId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -307,6 +407,10 @@ export class FolkRApp extends FolkShape {
|
||||||
this.dispatchEvent(new CustomEvent("close"));
|
this.dispatchEvent(new CustomEvent("close"));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Set up postMessage listener
|
||||||
|
this.#messageHandler = (e: MessageEvent) => this.#handleMessage(e);
|
||||||
|
window.addEventListener("message", this.#messageHandler);
|
||||||
|
|
||||||
// Load content
|
// Load content
|
||||||
if (this.#moduleId) {
|
if (this.#moduleId) {
|
||||||
this.#loadModule();
|
this.#loadModule();
|
||||||
|
|
@ -317,6 +421,86 @@ export class FolkRApp extends FolkShape {
|
||||||
return root;
|
return root;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
disconnectedCallback() {
|
||||||
|
super.disconnectedCallback?.();
|
||||||
|
if (this.#messageHandler) {
|
||||||
|
window.removeEventListener("message", this.#messageHandler);
|
||||||
|
this.#messageHandler = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#buildSwitcher(switcherEl: HTMLElement) {
|
||||||
|
const items = Object.entries(MODULE_META)
|
||||||
|
.map(([id, meta]) => `
|
||||||
|
<button class="rapp-switcher-item ${id === this.#moduleId ? "active" : ""}" data-module="${id}">
|
||||||
|
<span class="rapp-switcher-badge" style="background: ${meta.color}">${meta.badge}</span>
|
||||||
|
<span>${meta.name} ${meta.icon}</span>
|
||||||
|
</button>
|
||||||
|
`)
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
switcherEl.innerHTML = items;
|
||||||
|
|
||||||
|
switcherEl.querySelectorAll(".rapp-switcher-item").forEach((btn) => {
|
||||||
|
btn.addEventListener("click", (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const modId = (btn as HTMLElement).dataset.module;
|
||||||
|
if (modId && modId !== this.#moduleId) {
|
||||||
|
this.moduleId = modId;
|
||||||
|
this.#buildSwitcher(switcherEl);
|
||||||
|
}
|
||||||
|
switcherEl.classList.remove("open");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Handle postMessage from embedded iframe */
|
||||||
|
#handleMessage(e: MessageEvent) {
|
||||||
|
if (!this.#iframe) return;
|
||||||
|
|
||||||
|
// Only accept messages from our iframe
|
||||||
|
if (e.source !== this.#iframe.contentWindow) return;
|
||||||
|
|
||||||
|
const msg = e.data;
|
||||||
|
if (!msg || typeof msg !== "object") return;
|
||||||
|
|
||||||
|
// CommunitySync shape updates from the embedded module
|
||||||
|
if (msg.source === "rspace-canvas" && msg.type === "shape-updated") {
|
||||||
|
this.dispatchEvent(new CustomEvent("rapp-data", {
|
||||||
|
detail: { moduleId: this.#moduleId, shapeId: msg.shapeId, data: msg.data },
|
||||||
|
bubbles: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mark as connected
|
||||||
|
if (this.#statusEl) {
|
||||||
|
this.#statusEl.classList.add("connected");
|
||||||
|
this.#statusEl.title = "Connected — receiving data";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigation request from embedded module
|
||||||
|
if (msg.source === "rspace-rapp" && msg.type === "navigate" && msg.moduleId) {
|
||||||
|
this.moduleId = msg.moduleId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send context to the iframe after it loads */
|
||||||
|
#sendContext() {
|
||||||
|
if (!this.#iframe?.contentWindow) return;
|
||||||
|
try {
|
||||||
|
this.#iframe.contentWindow.postMessage({
|
||||||
|
source: "rspace-parent",
|
||||||
|
type: "context",
|
||||||
|
shapeId: this.id,
|
||||||
|
space: this.#spaceSlug,
|
||||||
|
moduleId: this.#moduleId,
|
||||||
|
embedded: true,
|
||||||
|
}, "*");
|
||||||
|
} catch {
|
||||||
|
// cross-origin or iframe not ready
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#loadModule() {
|
#loadModule() {
|
||||||
if (!this.#contentEl || !this.#moduleId) return;
|
if (!this.#contentEl || !this.#moduleId) return;
|
||||||
|
|
||||||
|
|
@ -333,6 +517,12 @@ export class FolkRApp extends FolkShape {
|
||||||
if (icon) icon.textContent = meta.icon;
|
if (icon) icon.textContent = meta.icon;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reset connection status
|
||||||
|
if (this.#statusEl) {
|
||||||
|
this.#statusEl.classList.remove("connected");
|
||||||
|
this.#statusEl.title = "Loading...";
|
||||||
|
}
|
||||||
|
|
||||||
// Show loading state
|
// Show loading state
|
||||||
this.#contentEl.innerHTML = `
|
this.#contentEl.innerHTML = `
|
||||||
<div class="rapp-loading">
|
<div class="rapp-loading">
|
||||||
|
|
@ -355,6 +545,9 @@ export class FolkRApp extends FolkShape {
|
||||||
// Remove loading indicator
|
// Remove loading indicator
|
||||||
const loading = this.#contentEl?.querySelector(".rapp-loading");
|
const loading = this.#contentEl?.querySelector(".rapp-loading");
|
||||||
if (loading) loading.remove();
|
if (loading) loading.remove();
|
||||||
|
|
||||||
|
// Send context to the newly loaded iframe
|
||||||
|
this.#sendContext();
|
||||||
});
|
});
|
||||||
|
|
||||||
iframe.addEventListener("error", () => {
|
iframe.addEventListener("error", () => {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue