1081 lines
29 KiB
TypeScript
1081 lines
29 KiB
TypeScript
/**
|
|
* <rstack-tab-bar> — Layered tab system for rSpace.
|
|
*
|
|
* Each tab is a "layer" — an rApp page within the current space.
|
|
* Supports two view modes:
|
|
* - flat: traditional tab bar (looking down at one layer)
|
|
* - stack: side view showing all layers as stacked strata with flows
|
|
*
|
|
* Attributes:
|
|
* active — the active layer ID
|
|
* space — current space slug
|
|
* view-mode — "flat" | "stack"
|
|
*
|
|
* Events:
|
|
* layer-switch — fired when user clicks a tab { detail: { layerId, moduleId } }
|
|
* layer-add — fired when user clicks + to add a layer
|
|
* layer-close — fired when user closes a tab { detail: { layerId } }
|
|
* layer-reorder — fired on drag reorder { detail: { layerId, newIndex } }
|
|
* view-toggle — fired when switching flat/stack view { detail: { mode } }
|
|
* flow-select — fired when a flow is clicked in stack view { detail: { flowId } }
|
|
*/
|
|
|
|
import type { Layer, LayerFlow, FlowKind } from "../../lib/layer-types";
|
|
import { FLOW_COLORS, FLOW_LABELS } from "../../lib/layer-types";
|
|
|
|
// Re-export badge info so the tab bar can show module colors
|
|
const MODULE_BADGES: Record<string, { badge: string; color: string }> = {
|
|
canvas: { badge: "rS", color: "#5eead4" },
|
|
notes: { badge: "rN", color: "#fcd34d" },
|
|
pubs: { badge: "rP", color: "#fda4af" },
|
|
swag: { badge: "rSw", color: "#fda4af" },
|
|
splat: { badge: "r3", color: "#d8b4fe" },
|
|
cal: { badge: "rC", color: "#7dd3fc" },
|
|
trips: { badge: "rT", color: "#6ee7b7" },
|
|
maps: { badge: "rM", color: "#86efac" },
|
|
chats: { badge: "rCh", color: "#6ee7b7" },
|
|
inbox: { badge: "rI", color: "#a5b4fc" },
|
|
mail: { badge: "rMa", color: "#93c5fd" },
|
|
forum: { badge: "rFo", color: "#fcd34d" },
|
|
choices: { badge: "rCo", color: "#f0abfc" },
|
|
vote: { badge: "rV", color: "#c4b5fd" },
|
|
funds: { badge: "rF", color: "#bef264" },
|
|
wallet: { badge: "rW", color: "#fde047" },
|
|
cart: { badge: "rCt", color: "#fdba74" },
|
|
auctions: { badge: "rA", color: "#fca5a5" },
|
|
providers: { badge: "rPr", color: "#fdba74" },
|
|
tube: { badge: "rTu", color: "#f9a8d4" },
|
|
photos: { badge: "rPh", color: "#f9a8d4" },
|
|
network: { badge: "rNe", color: "#93c5fd" },
|
|
socials: { badge: "rSo", color: "#7dd3fc" },
|
|
files: { badge: "rFi", color: "#67e8f9" },
|
|
books: { badge: "rB", color: "#fda4af" },
|
|
data: { badge: "rD", color: "#d8b4fe" },
|
|
work: { badge: "rWo", color: "#cbd5e1" },
|
|
};
|
|
|
|
export class RStackTabBar extends HTMLElement {
|
|
#shadow: ShadowRoot;
|
|
#layers: Layer[] = [];
|
|
#flows: LayerFlow[] = [];
|
|
#viewMode: "flat" | "stack" = "flat";
|
|
#draggedTabId: string | null = null;
|
|
#addMenuOpen = false;
|
|
#flowDragSource: string | null = null;
|
|
#flowDragTarget: string | null = null;
|
|
#flowDialogOpen = false;
|
|
#flowDialogSourceId = "";
|
|
#flowDialogTargetId = "";
|
|
|
|
constructor() {
|
|
super();
|
|
this.#shadow = this.attachShadow({ mode: "open" });
|
|
}
|
|
|
|
static get observedAttributes() {
|
|
return ["active", "space", "view-mode"];
|
|
}
|
|
|
|
get active(): string {
|
|
return this.getAttribute("active") || "";
|
|
}
|
|
|
|
set active(val: string) {
|
|
this.setAttribute("active", val);
|
|
}
|
|
|
|
get space(): string {
|
|
return this.getAttribute("space") || "";
|
|
}
|
|
|
|
get viewMode(): "flat" | "stack" {
|
|
return (this.getAttribute("view-mode") as "flat" | "stack") || "flat";
|
|
}
|
|
|
|
set viewMode(val: "flat" | "stack") {
|
|
this.setAttribute("view-mode", val);
|
|
}
|
|
|
|
connectedCallback() {
|
|
this.#render();
|
|
}
|
|
|
|
attributeChangedCallback() {
|
|
this.#viewMode = this.viewMode;
|
|
this.#render();
|
|
}
|
|
|
|
/** Set the layer list (call from outside) */
|
|
setLayers(layers: Layer[]) {
|
|
this.#layers = [...layers].sort((a, b) => a.order - b.order);
|
|
this.#render();
|
|
}
|
|
|
|
/** Set the inter-layer flows (for stack view) */
|
|
setFlows(flows: LayerFlow[]) {
|
|
this.#flows = flows;
|
|
if (this.#viewMode === "stack") this.#render();
|
|
}
|
|
|
|
/** Add a single layer */
|
|
addLayer(layer: Layer) {
|
|
this.#layers.push(layer);
|
|
this.#layers.sort((a, b) => a.order - b.order);
|
|
this.#render();
|
|
}
|
|
|
|
/** Remove a layer by ID */
|
|
removeLayer(layerId: string) {
|
|
this.#layers = this.#layers.filter(l => l.id !== layerId);
|
|
this.#render();
|
|
}
|
|
|
|
/** Update a layer */
|
|
updateLayer(layerId: string, updates: Partial<Layer>) {
|
|
const layer = this.#layers.find(l => l.id === layerId);
|
|
if (layer) {
|
|
Object.assign(layer, updates);
|
|
this.#layers.sort((a, b) => a.order - b.order);
|
|
this.#render();
|
|
}
|
|
}
|
|
|
|
// ── Render ──
|
|
|
|
#render() {
|
|
const active = this.active;
|
|
|
|
this.#shadow.innerHTML = `
|
|
<style>${STYLES}</style>
|
|
<div class="tab-bar" data-view="${this.#viewMode}">
|
|
<div class="tabs-scroll">
|
|
${this.#layers.map(l => this.#renderTab(l, active)).join("")}
|
|
<button class="tab-add" id="add-btn" title="Add layer">+</button>
|
|
${this.#addMenuOpen ? this.#renderAddMenu() : ""}
|
|
</div>
|
|
<div class="tab-actions">
|
|
<button class="view-toggle ${this.#viewMode === "stack" ? "active" : ""}"
|
|
id="view-toggle" title="Toggle stack view">
|
|
${this.#viewMode === "stack" ? ICON_FLAT : ICON_STACK}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
${this.#viewMode === "stack" ? this.#renderStackView() : ""}
|
|
`;
|
|
|
|
this.#attachEvents();
|
|
}
|
|
|
|
#renderTab(layer: Layer, activeId: string): string {
|
|
const badge = MODULE_BADGES[layer.moduleId];
|
|
const isActive = layer.id === activeId;
|
|
const badgeColor = layer.color || badge?.color || "#94a3b8";
|
|
|
|
return `
|
|
<div class="tab ${isActive ? "active" : ""}"
|
|
data-layer-id="${layer.id}"
|
|
data-module-id="${layer.moduleId}"
|
|
draggable="true">
|
|
<span class="tab-indicator" style="background:${badgeColor}"></span>
|
|
<span class="tab-badge" style="background:${badgeColor}">${badge?.badge || layer.moduleId.slice(0, 2)}</span>
|
|
<span class="tab-label">${layer.label}</span>
|
|
${this.#layers.length > 1 ? `<button class="tab-close" data-close="${layer.id}">×</button>` : ""}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
#renderAddMenu(): string {
|
|
// Group available modules (show ones not yet added as layers)
|
|
const existingModuleIds = new Set(this.#layers.map(l => l.moduleId));
|
|
const available = Object.entries(MODULE_BADGES)
|
|
.filter(([id]) => !existingModuleIds.has(id))
|
|
.map(([id, info]) => ({ id, ...info }));
|
|
|
|
if (available.length === 0) {
|
|
return `<div class="add-menu" id="add-menu"><div class="add-menu-empty">All modules added</div></div>`;
|
|
}
|
|
|
|
return `
|
|
<div class="add-menu" id="add-menu">
|
|
${available.map(m => `
|
|
<button class="add-menu-item" data-add-module="${m.id}">
|
|
<span class="add-menu-badge" style="background:${m.color}">${m.badge}</span>
|
|
<span>${m.id}</span>
|
|
</button>
|
|
`).join("")}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
#renderStackView(): string {
|
|
const layerCount = this.#layers.length;
|
|
if (layerCount === 0) return "";
|
|
|
|
const layerHeight = 60;
|
|
const layerGap = 40;
|
|
const totalHeight = layerCount * layerHeight + (layerCount - 1) * layerGap + 80;
|
|
const width = 600;
|
|
|
|
// Build layer rects and flow arcs
|
|
let layersSvg = "";
|
|
let flowsSvg = "";
|
|
const layerPositions = new Map<string, { x: number; y: number; w: number; h: number }>();
|
|
|
|
this.#layers.forEach((layer, i) => {
|
|
const y = 40 + i * (layerHeight + layerGap);
|
|
const x = 40;
|
|
const w = width - 80;
|
|
const badge = MODULE_BADGES[layer.moduleId];
|
|
const color = layer.color || badge?.color || "#94a3b8";
|
|
|
|
layerPositions.set(layer.id, { x, y, w, h: layerHeight });
|
|
|
|
const isActive = layer.id === this.active;
|
|
layersSvg += `
|
|
<g class="stack-layer ${isActive ? "stack-layer--active" : ""}"
|
|
data-layer-id="${layer.id}">
|
|
<rect x="${x}" y="${y}" width="${w}" height="${layerHeight}"
|
|
rx="8" fill="${color}20" stroke="${color}" stroke-width="${isActive ? 2 : 1}"
|
|
style="cursor:pointer" />
|
|
<text x="${x + 14}" y="${y + layerHeight / 2 + 5}"
|
|
fill="${color}" font-size="13" font-weight="600">${layer.label}</text>
|
|
<text x="${x + w - 14}" y="${y + layerHeight / 2 + 5}"
|
|
fill="${color}80" font-size="11" text-anchor="end">${badge?.badge || layer.moduleId}</text>
|
|
</g>
|
|
`;
|
|
});
|
|
|
|
// Draw flows as curved arcs on the right side
|
|
for (const flow of this.#flows) {
|
|
const src = layerPositions.get(flow.sourceLayerId);
|
|
const tgt = layerPositions.get(flow.targetLayerId);
|
|
if (!src || !tgt) continue;
|
|
|
|
const srcY = src.y + src.h / 2;
|
|
const tgtY = tgt.y + tgt.h / 2;
|
|
const rightX = src.x + src.w;
|
|
const arcOut = 30 + flow.strength * 40;
|
|
const color = flow.color || FLOW_COLORS[flow.kind] || "#94a3b8";
|
|
const strokeWidth = 1.5 + flow.strength * 3;
|
|
|
|
// Going down or up — arc curves right
|
|
const direction = tgtY > srcY ? 1 : -1;
|
|
const midY = (srcY + tgtY) / 2;
|
|
|
|
flowsSvg += `
|
|
<g class="stack-flow" data-flow-id="${flow.id}">
|
|
<path d="M ${rightX} ${srcY}
|
|
C ${rightX + arcOut} ${srcY},
|
|
${rightX + arcOut} ${tgtY},
|
|
${rightX} ${tgtY}"
|
|
fill="none" stroke="${color}" stroke-width="${strokeWidth}"
|
|
stroke-dasharray="${flow.active ? "none" : "4 4"}"
|
|
opacity="${flow.active ? 0.8 : 0.4}"
|
|
style="cursor:pointer" />
|
|
<circle cx="${rightX}" cy="${srcY}" r="3" fill="${color}" />
|
|
<circle cx="${rightX}" cy="${tgtY}" r="3" fill="${color}" />
|
|
${flow.label ? `
|
|
<text x="${rightX + arcOut + 6}" y="${midY + 4}"
|
|
fill="${color}" font-size="10" opacity="0.7">${flow.label}</text>
|
|
` : ""}
|
|
<!-- Arrow head -->
|
|
<polygon points="${rightX},${tgtY} ${rightX + 7},${tgtY - 4 * direction} ${rightX + 7},${tgtY + 4 * direction}"
|
|
fill="${color}" opacity="${flow.active ? 0.8 : 0.4}" />
|
|
</g>
|
|
`;
|
|
}
|
|
|
|
// Flow kind legend
|
|
const activeKinds = new Set(this.#flows.map(f => f.kind));
|
|
let legendSvg = "";
|
|
let legendX = 50;
|
|
for (const kind of activeKinds) {
|
|
const color = FLOW_COLORS[kind];
|
|
legendSvg += `
|
|
<circle cx="${legendX}" cy="${totalHeight - 16}" r="4" fill="${color}" />
|
|
<text x="${legendX + 8}" y="${totalHeight - 12}" fill="${color}" font-size="10">${FLOW_LABELS[kind]}</text>
|
|
`;
|
|
legendX += FLOW_LABELS[kind].length * 7 + 24;
|
|
}
|
|
|
|
// Flow creation hint
|
|
const hintSvg = this.#layers.length >= 2 ? `
|
|
<text x="${width / 2}" y="${totalHeight - 2}" fill="#475569" font-size="9" text-anchor="middle">
|
|
Drag from one layer to another to create a flow
|
|
</text>
|
|
` : "";
|
|
|
|
return `
|
|
<div class="stack-view">
|
|
<svg width="100%" height="${totalHeight}" viewBox="0 0 ${width} ${totalHeight}">
|
|
${flowsSvg}
|
|
${layersSvg}
|
|
${legendSvg}
|
|
${hintSvg}
|
|
</svg>
|
|
${this.#flowDialogOpen ? this.#renderFlowDialog() : ""}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// ── Flow creation dialog ──
|
|
|
|
#renderFlowDialog(): string {
|
|
const srcLayer = this.#layers.find(l => l.id === this.#flowDialogSourceId);
|
|
const tgtLayer = this.#layers.find(l => l.id === this.#flowDialogTargetId);
|
|
if (!srcLayer || !tgtLayer) return "";
|
|
|
|
const srcBadge = MODULE_BADGES[srcLayer.moduleId];
|
|
const tgtBadge = MODULE_BADGES[tgtLayer.moduleId];
|
|
|
|
const kinds: FlowKind[] = ["economic", "trust", "data", "attention", "governance", "resource"];
|
|
|
|
return `
|
|
<div class="flow-dialog" id="flow-dialog">
|
|
<div class="flow-dialog-header">New Flow</div>
|
|
<div class="flow-dialog-route">
|
|
<span class="flow-dialog-badge" style="background:${srcBadge?.color || "#94a3b8"}">${srcBadge?.badge || srcLayer.moduleId}</span>
|
|
<span class="flow-dialog-arrow">→</span>
|
|
<span class="flow-dialog-badge" style="background:${tgtBadge?.color || "#94a3b8"}">${tgtBadge?.badge || tgtLayer.moduleId}</span>
|
|
</div>
|
|
<div class="flow-dialog-field">
|
|
<label class="flow-dialog-label">Flow Type</label>
|
|
<div class="flow-kind-grid" id="flow-kind-grid">
|
|
${kinds.map(k => `
|
|
<button class="flow-kind-btn" data-kind="${k}" style="--kind-color:${FLOW_COLORS[k]}">
|
|
<span class="flow-kind-dot" style="background:${FLOW_COLORS[k]}"></span>
|
|
${FLOW_LABELS[k]}
|
|
</button>
|
|
`).join("")}
|
|
</div>
|
|
</div>
|
|
<div class="flow-dialog-field">
|
|
<label class="flow-dialog-label">Label (optional)</label>
|
|
<input class="flow-dialog-input" id="flow-label-input" type="text" placeholder="e.g. Budget allocation" />
|
|
</div>
|
|
<div class="flow-dialog-field">
|
|
<label class="flow-dialog-label">Strength</label>
|
|
<input class="flow-dialog-range" id="flow-strength-input" type="range" min="0.1" max="1" step="0.1" value="0.5" />
|
|
</div>
|
|
<div class="flow-dialog-actions">
|
|
<button class="flow-dialog-cancel" id="flow-cancel">Cancel</button>
|
|
<button class="flow-dialog-create" id="flow-create">Create Flow</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
#openFlowDialog(sourceLayerId: string, targetLayerId: string) {
|
|
this.#flowDialogOpen = true;
|
|
this.#flowDialogSourceId = sourceLayerId;
|
|
this.#flowDialogTargetId = targetLayerId;
|
|
this.#render();
|
|
}
|
|
|
|
#closeFlowDialog() {
|
|
this.#flowDialogOpen = false;
|
|
this.#flowDialogSourceId = "";
|
|
this.#flowDialogTargetId = "";
|
|
this.#render();
|
|
}
|
|
|
|
// ── Events ──
|
|
|
|
#attachEvents() {
|
|
// Tab clicks
|
|
this.#shadow.querySelectorAll<HTMLElement>(".tab").forEach(tab => {
|
|
tab.addEventListener("click", (e) => {
|
|
const target = e.target as HTMLElement;
|
|
if (target.classList.contains("tab-close")) return;
|
|
const layerId = tab.dataset.layerId!;
|
|
const moduleId = tab.dataset.moduleId!;
|
|
this.active = layerId;
|
|
this.dispatchEvent(new CustomEvent("layer-switch", {
|
|
detail: { layerId, moduleId },
|
|
bubbles: true,
|
|
}));
|
|
});
|
|
|
|
// Drag-and-drop reorder
|
|
tab.addEventListener("dragstart", (e) => {
|
|
this.#draggedTabId = tab.dataset.layerId!;
|
|
tab.classList.add("dragging");
|
|
(e as DragEvent).dataTransfer!.effectAllowed = "move";
|
|
});
|
|
|
|
tab.addEventListener("dragend", () => {
|
|
tab.classList.remove("dragging");
|
|
this.#draggedTabId = null;
|
|
});
|
|
|
|
tab.addEventListener("dragover", (e) => {
|
|
e.preventDefault();
|
|
(e as DragEvent).dataTransfer!.dropEffect = "move";
|
|
tab.classList.add("drag-over");
|
|
});
|
|
|
|
tab.addEventListener("dragleave", () => {
|
|
tab.classList.remove("drag-over");
|
|
});
|
|
|
|
tab.addEventListener("drop", (e) => {
|
|
e.preventDefault();
|
|
tab.classList.remove("drag-over");
|
|
if (!this.#draggedTabId || this.#draggedTabId === tab.dataset.layerId) return;
|
|
|
|
const targetIdx = this.#layers.findIndex(l => l.id === tab.dataset.layerId);
|
|
this.dispatchEvent(new CustomEvent("layer-reorder", {
|
|
detail: { layerId: this.#draggedTabId, newIndex: targetIdx },
|
|
bubbles: true,
|
|
}));
|
|
});
|
|
});
|
|
|
|
// Close buttons
|
|
this.#shadow.querySelectorAll<HTMLElement>(".tab-close").forEach(btn => {
|
|
btn.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
const layerId = (btn as HTMLElement).dataset.close!;
|
|
this.dispatchEvent(new CustomEvent("layer-close", {
|
|
detail: { layerId },
|
|
bubbles: true,
|
|
}));
|
|
});
|
|
});
|
|
|
|
// Add button
|
|
const addBtn = this.#shadow.getElementById("add-btn");
|
|
addBtn?.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
this.#addMenuOpen = !this.#addMenuOpen;
|
|
this.#render();
|
|
});
|
|
|
|
// Add menu items
|
|
this.#shadow.querySelectorAll<HTMLElement>(".add-menu-item").forEach(item => {
|
|
item.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
const moduleId = item.dataset.addModule!;
|
|
this.#addMenuOpen = false;
|
|
this.dispatchEvent(new CustomEvent("layer-add", {
|
|
detail: { moduleId },
|
|
bubbles: true,
|
|
}));
|
|
});
|
|
});
|
|
|
|
// Close add menu on outside click
|
|
if (this.#addMenuOpen) {
|
|
const handler = () => {
|
|
this.#addMenuOpen = false;
|
|
this.#render();
|
|
document.removeEventListener("click", handler);
|
|
};
|
|
setTimeout(() => document.addEventListener("click", handler), 0);
|
|
}
|
|
|
|
// View toggle
|
|
const viewToggle = this.#shadow.getElementById("view-toggle");
|
|
viewToggle?.addEventListener("click", () => {
|
|
const newMode = this.#viewMode === "flat" ? "stack" : "flat";
|
|
this.#viewMode = newMode;
|
|
this.setAttribute("view-mode", newMode);
|
|
this.dispatchEvent(new CustomEvent("view-toggle", {
|
|
detail: { mode: newMode },
|
|
bubbles: true,
|
|
}));
|
|
this.#render();
|
|
});
|
|
|
|
// Stack view layer clicks + drag-to-connect
|
|
this.#shadow.querySelectorAll<SVGGElement>(".stack-layer").forEach(g => {
|
|
const layerId = g.dataset.layerId!;
|
|
|
|
// Click to switch layer
|
|
g.addEventListener("click", () => {
|
|
if (this.#flowDragSource) return; // ignore if mid-drag
|
|
const layer = this.#layers.find(l => l.id === layerId);
|
|
if (layer) {
|
|
this.active = layerId;
|
|
this.dispatchEvent(new CustomEvent("layer-switch", {
|
|
detail: { layerId, moduleId: layer.moduleId },
|
|
bubbles: true,
|
|
}));
|
|
}
|
|
});
|
|
|
|
// Drag-to-connect: mousedown starts a flow drag
|
|
g.addEventListener("mousedown", (e) => {
|
|
if ((e as MouseEvent).button !== 0) return;
|
|
this.#flowDragSource = layerId;
|
|
g.classList.add("flow-drag-source");
|
|
});
|
|
|
|
g.addEventListener("mouseenter", () => {
|
|
if (this.#flowDragSource && this.#flowDragSource !== layerId) {
|
|
this.#flowDragTarget = layerId;
|
|
g.classList.add("flow-drag-target");
|
|
}
|
|
});
|
|
|
|
g.addEventListener("mouseleave", () => {
|
|
if (this.#flowDragTarget === layerId) {
|
|
this.#flowDragTarget = null;
|
|
g.classList.remove("flow-drag-target");
|
|
}
|
|
});
|
|
});
|
|
|
|
// Global mouseup completes the flow drag
|
|
const svgEl = this.#shadow.querySelector<SVGElement>(".stack-view svg");
|
|
if (svgEl) {
|
|
svgEl.addEventListener("mouseup", () => {
|
|
if (this.#flowDragSource && this.#flowDragTarget) {
|
|
this.#openFlowDialog(this.#flowDragSource, this.#flowDragTarget);
|
|
}
|
|
// Reset drag state
|
|
this.#flowDragSource = null;
|
|
this.#flowDragTarget = null;
|
|
this.#shadow.querySelectorAll(".flow-drag-source, .flow-drag-target").forEach(el => {
|
|
el.classList.remove("flow-drag-source", "flow-drag-target");
|
|
});
|
|
});
|
|
}
|
|
|
|
// Stack view flow clicks — select or delete
|
|
this.#shadow.querySelectorAll<SVGGElement>(".stack-flow").forEach(g => {
|
|
g.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
const flowId = g.dataset.flowId!;
|
|
this.dispatchEvent(new CustomEvent("flow-select", {
|
|
detail: { flowId },
|
|
bubbles: true,
|
|
}));
|
|
});
|
|
|
|
// Right-click to delete flow
|
|
g.addEventListener("contextmenu", (e) => {
|
|
e.preventDefault();
|
|
const flowId = g.dataset.flowId!;
|
|
const flow = this.#flows.find(f => f.id === flowId);
|
|
if (flow && confirm(`Remove ${FLOW_LABELS[flow.kind]} flow${flow.label ? `: ${flow.label}` : ""}?`)) {
|
|
this.dispatchEvent(new CustomEvent("flow-remove", {
|
|
detail: { flowId },
|
|
bubbles: true,
|
|
}));
|
|
}
|
|
});
|
|
});
|
|
|
|
// Flow dialog events
|
|
if (this.#flowDialogOpen) {
|
|
let selectedKind: FlowKind = "data";
|
|
|
|
this.#shadow.querySelectorAll<HTMLElement>(".flow-kind-btn").forEach(btn => {
|
|
btn.addEventListener("click", () => {
|
|
this.#shadow.querySelectorAll(".flow-kind-btn").forEach(b => b.classList.remove("selected"));
|
|
btn.classList.add("selected");
|
|
selectedKind = btn.dataset.kind as FlowKind;
|
|
});
|
|
});
|
|
|
|
// Select "data" by default
|
|
const defaultBtn = this.#shadow.querySelector('.flow-kind-btn[data-kind="data"]');
|
|
defaultBtn?.classList.add("selected");
|
|
|
|
this.#shadow.getElementById("flow-cancel")?.addEventListener("click", () => {
|
|
this.#closeFlowDialog();
|
|
});
|
|
|
|
this.#shadow.getElementById("flow-create")?.addEventListener("click", () => {
|
|
const label = (this.#shadow.getElementById("flow-label-input") as HTMLInputElement)?.value || "";
|
|
const strength = parseFloat((this.#shadow.getElementById("flow-strength-input") as HTMLInputElement)?.value || "0.5");
|
|
|
|
const flowId = `flow-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
|
this.dispatchEvent(new CustomEvent("flow-create", {
|
|
detail: {
|
|
flow: {
|
|
id: flowId,
|
|
kind: selectedKind,
|
|
sourceLayerId: this.#flowDialogSourceId,
|
|
targetLayerId: this.#flowDialogTargetId,
|
|
label: label || undefined,
|
|
strength,
|
|
active: true,
|
|
}
|
|
},
|
|
bubbles: true,
|
|
}));
|
|
|
|
this.#closeFlowDialog();
|
|
});
|
|
}
|
|
}
|
|
|
|
static define(tag = "rstack-tab-bar") {
|
|
if (!customElements.get(tag)) customElements.define(tag, RStackTabBar);
|
|
}
|
|
}
|
|
|
|
// ── SVG Icons ──
|
|
|
|
const ICON_STACK = `<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
<rect x="2" y="2" width="12" height="3" rx="1" />
|
|
<rect x="2" y="6.5" width="12" height="3" rx="1" />
|
|
<rect x="2" y="11" width="12" height="3" rx="1" />
|
|
</svg>`;
|
|
|
|
const ICON_FLAT = `<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
<rect x="1" y="4" width="14" height="9" rx="1.5" />
|
|
<path d="M1 6h14" />
|
|
<circle cx="3.5" cy="5" r="0.5" fill="currentColor" />
|
|
<circle cx="5.5" cy="5" r="0.5" fill="currentColor" />
|
|
<circle cx="7.5" cy="5" r="0.5" fill="currentColor" />
|
|
</svg>`;
|
|
|
|
// ── Styles ──
|
|
|
|
const STYLES = `
|
|
:host { display: block; }
|
|
|
|
/* ── Tab bar (flat mode) ── */
|
|
|
|
.tab-bar {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0;
|
|
height: 36px;
|
|
padding: 0 8px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.tabs-scroll {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 2px;
|
|
flex: 1;
|
|
min-width: 0;
|
|
overflow-x: auto;
|
|
scrollbar-width: none;
|
|
position: relative;
|
|
}
|
|
.tabs-scroll::-webkit-scrollbar { display: none; }
|
|
|
|
.tab-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
flex-shrink: 0;
|
|
margin-left: 4px;
|
|
}
|
|
|
|
/* ── Individual tab ── */
|
|
|
|
.tab {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
padding: 4px 12px;
|
|
border-radius: 6px 6px 0 0;
|
|
cursor: pointer;
|
|
white-space: nowrap;
|
|
font-size: 0.8rem;
|
|
font-weight: 500;
|
|
transition: background 0.15s, opacity 0.15s;
|
|
user-select: none;
|
|
position: relative;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
:host-context([data-theme="dark"]) .tab {
|
|
color: #94a3b8;
|
|
background: transparent;
|
|
}
|
|
:host-context([data-theme="dark"]) .tab:hover {
|
|
background: rgba(255,255,255,0.05);
|
|
color: #e2e8f0;
|
|
}
|
|
:host-context([data-theme="dark"]) .tab.active {
|
|
background: rgba(255,255,255,0.08);
|
|
color: #f1f5f9;
|
|
}
|
|
|
|
:host-context([data-theme="light"]) .tab {
|
|
color: #64748b;
|
|
background: transparent;
|
|
}
|
|
:host-context([data-theme="light"]) .tab:hover {
|
|
background: rgba(0,0,0,0.04);
|
|
color: #1e293b;
|
|
}
|
|
:host-context([data-theme="light"]) .tab.active {
|
|
background: rgba(0,0,0,0.06);
|
|
color: #0f172a;
|
|
}
|
|
|
|
/* Active indicator line at bottom */
|
|
.tab-indicator {
|
|
position: absolute;
|
|
bottom: 0;
|
|
left: 8px;
|
|
right: 8px;
|
|
height: 2px;
|
|
border-radius: 2px 2px 0 0;
|
|
opacity: 0;
|
|
transition: opacity 0.15s;
|
|
}
|
|
.tab.active .tab-indicator { opacity: 1; }
|
|
|
|
.tab-badge {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 20px;
|
|
height: 20px;
|
|
border-radius: 4px;
|
|
font-size: 0.55rem;
|
|
font-weight: 900;
|
|
color: #0f172a;
|
|
line-height: 1;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.tab-label {
|
|
font-size: 0.8rem;
|
|
}
|
|
|
|
.tab-close {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 16px;
|
|
height: 16px;
|
|
border: none;
|
|
border-radius: 3px;
|
|
background: transparent;
|
|
color: inherit;
|
|
font-size: 0.85rem;
|
|
cursor: pointer;
|
|
opacity: 0;
|
|
transition: opacity 0.15s, background 0.15s;
|
|
padding: 0;
|
|
line-height: 1;
|
|
}
|
|
.tab:hover .tab-close { opacity: 0.5; }
|
|
.tab-close:hover { opacity: 1 !important; background: rgba(239,68,68,0.2); color: #ef4444; }
|
|
|
|
/* ── Drag states ── */
|
|
|
|
.tab.dragging { opacity: 0.4; }
|
|
.tab.drag-over { box-shadow: inset 2px 0 0 #22d3ee; }
|
|
|
|
/* ── Add button ── */
|
|
|
|
.tab-add {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 24px;
|
|
height: 24px;
|
|
border: 1px dashed rgba(148,163,184,0.3);
|
|
border-radius: 5px;
|
|
background: transparent;
|
|
color: #64748b;
|
|
font-size: 0.9rem;
|
|
cursor: pointer;
|
|
transition: border-color 0.15s, color 0.15s, background 0.15s;
|
|
flex-shrink: 0;
|
|
margin-left: 4px;
|
|
}
|
|
.tab-add:hover {
|
|
border-color: #22d3ee;
|
|
color: #22d3ee;
|
|
background: rgba(34,211,238,0.08);
|
|
}
|
|
|
|
/* ── Add menu ── */
|
|
|
|
.add-menu {
|
|
position: absolute;
|
|
top: 100%;
|
|
left: auto;
|
|
right: 0;
|
|
margin-top: 4px;
|
|
min-width: 180px;
|
|
max-height: 300px;
|
|
overflow-y: auto;
|
|
border-radius: 8px;
|
|
padding: 4px;
|
|
z-index: 100;
|
|
box-shadow: 0 8px 30px rgba(0,0,0,0.25);
|
|
}
|
|
:host-context([data-theme="dark"]) .add-menu {
|
|
background: #1e293b;
|
|
border: 1px solid rgba(255,255,255,0.1);
|
|
}
|
|
:host-context([data-theme="light"]) .add-menu {
|
|
background: white;
|
|
border: 1px solid rgba(0,0,0,0.1);
|
|
}
|
|
|
|
.add-menu-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
width: 100%;
|
|
padding: 6px 10px;
|
|
border: none;
|
|
border-radius: 5px;
|
|
background: transparent;
|
|
color: inherit;
|
|
font-size: 0.8rem;
|
|
cursor: pointer;
|
|
text-align: left;
|
|
transition: background 0.12s;
|
|
}
|
|
:host-context([data-theme="dark"]) .add-menu-item:hover { background: rgba(255,255,255,0.06); }
|
|
:host-context([data-theme="light"]) .add-menu-item:hover { background: rgba(0,0,0,0.04); }
|
|
|
|
.add-menu-badge {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 22px;
|
|
height: 22px;
|
|
border-radius: 5px;
|
|
font-size: 0.55rem;
|
|
font-weight: 900;
|
|
color: #0f172a;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.add-menu-empty {
|
|
padding: 12px;
|
|
text-align: center;
|
|
font-size: 0.75rem;
|
|
opacity: 0.5;
|
|
}
|
|
|
|
/* ── View toggle ── */
|
|
|
|
.view-toggle {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 28px;
|
|
height: 28px;
|
|
border: none;
|
|
border-radius: 6px;
|
|
background: transparent;
|
|
color: #64748b;
|
|
cursor: pointer;
|
|
transition: background 0.15s, color 0.15s;
|
|
}
|
|
.view-toggle:hover {
|
|
background: rgba(255,255,255,0.08);
|
|
color: #e2e8f0;
|
|
}
|
|
:host-context([data-theme="light"]) .view-toggle:hover {
|
|
background: rgba(0,0,0,0.05);
|
|
color: #1e293b;
|
|
}
|
|
.view-toggle.active {
|
|
color: #22d3ee;
|
|
background: rgba(34,211,238,0.1);
|
|
}
|
|
|
|
/* ── Stack view ── */
|
|
|
|
.stack-view {
|
|
padding: 12px;
|
|
overflow: auto;
|
|
max-height: 50vh;
|
|
transition: max-height 0.3s ease;
|
|
}
|
|
|
|
:host-context([data-theme="dark"]) .stack-view {
|
|
background: rgba(15,23,42,0.5);
|
|
border-bottom: 1px solid rgba(255,255,255,0.06);
|
|
}
|
|
:host-context([data-theme="light"]) .stack-view {
|
|
background: rgba(248,250,252,0.8);
|
|
border-bottom: 1px solid rgba(0,0,0,0.06);
|
|
}
|
|
|
|
.stack-view svg {
|
|
display: block;
|
|
margin: 0 auto;
|
|
}
|
|
|
|
.stack-layer { cursor: pointer; }
|
|
.stack-layer:hover rect { stroke-width: 2.5; }
|
|
.stack-layer--active rect { stroke-dasharray: none; }
|
|
|
|
/* Drag-to-connect visual states */
|
|
.stack-layer.flow-drag-source rect { stroke-dasharray: 4 2; stroke-width: 2.5; }
|
|
.stack-layer.flow-drag-target rect { stroke-width: 3; filter: brightness(1.3); }
|
|
|
|
.stack-flow { cursor: pointer; }
|
|
.stack-flow:hover path { stroke-width: 4 !important; opacity: 1 !important; }
|
|
|
|
/* ── Flow creation dialog ── */
|
|
|
|
.flow-dialog {
|
|
position: absolute;
|
|
top: 50%;
|
|
left: 50%;
|
|
transform: translate(-50%, -50%);
|
|
width: 280px;
|
|
border-radius: 12px;
|
|
padding: 16px;
|
|
z-index: 50;
|
|
box-shadow: 0 12px 40px rgba(0,0,0,0.4);
|
|
}
|
|
:host-context([data-theme="dark"]) .flow-dialog {
|
|
background: #1e293b;
|
|
border: 1px solid rgba(255,255,255,0.1);
|
|
color: #e2e8f0;
|
|
}
|
|
:host-context([data-theme="light"]) .flow-dialog {
|
|
background: white;
|
|
border: 1px solid rgba(0,0,0,0.1);
|
|
color: #1e293b;
|
|
}
|
|
|
|
.flow-dialog-header {
|
|
font-size: 0.85rem;
|
|
font-weight: 700;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.flow-dialog-route {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
margin-bottom: 12px;
|
|
justify-content: center;
|
|
}
|
|
|
|
.flow-dialog-badge {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 3px 8px;
|
|
border-radius: 5px;
|
|
font-size: 0.6rem;
|
|
font-weight: 900;
|
|
color: #0f172a;
|
|
}
|
|
|
|
.flow-dialog-arrow {
|
|
font-size: 1rem;
|
|
opacity: 0.4;
|
|
}
|
|
|
|
.flow-dialog-field {
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.flow-dialog-label {
|
|
display: block;
|
|
font-size: 0.6rem;
|
|
font-weight: 700;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
opacity: 0.5;
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
.flow-kind-grid {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 4px;
|
|
}
|
|
|
|
.flow-kind-btn {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
padding: 5px 8px;
|
|
border: 1px solid transparent;
|
|
border-radius: 5px;
|
|
background: transparent;
|
|
color: inherit;
|
|
font-size: 0.7rem;
|
|
cursor: pointer;
|
|
transition: background 0.12s, border-color 0.12s;
|
|
}
|
|
.flow-kind-btn:hover { background: rgba(255,255,255,0.05); }
|
|
.flow-kind-btn.selected {
|
|
border-color: var(--kind-color);
|
|
background: color-mix(in srgb, var(--kind-color) 10%, transparent);
|
|
}
|
|
|
|
.flow-kind-dot {
|
|
width: 8px;
|
|
height: 8px;
|
|
border-radius: 50%;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.flow-dialog-input {
|
|
width: 100%;
|
|
padding: 6px 8px;
|
|
border: 1px solid rgba(255,255,255,0.1);
|
|
border-radius: 5px;
|
|
background: rgba(255,255,255,0.04);
|
|
color: inherit;
|
|
font-size: 0.75rem;
|
|
outline: none;
|
|
}
|
|
.flow-dialog-input:focus { border-color: rgba(34,211,238,0.4); }
|
|
:host-context([data-theme="light"]) .flow-dialog-input {
|
|
border-color: rgba(0,0,0,0.1);
|
|
background: rgba(0,0,0,0.02);
|
|
}
|
|
|
|
.flow-dialog-range {
|
|
width: 100%;
|
|
accent-color: #22d3ee;
|
|
}
|
|
|
|
.flow-dialog-actions {
|
|
display: flex;
|
|
gap: 6px;
|
|
justify-content: flex-end;
|
|
margin-top: 4px;
|
|
}
|
|
|
|
.flow-dialog-cancel, .flow-dialog-create {
|
|
padding: 5px 14px;
|
|
border-radius: 6px;
|
|
font-size: 0.7rem;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.flow-dialog-cancel {
|
|
border: 1px solid rgba(255,255,255,0.1);
|
|
background: transparent;
|
|
color: inherit;
|
|
opacity: 0.6;
|
|
}
|
|
.flow-dialog-cancel:hover { opacity: 1; }
|
|
|
|
.flow-dialog-create {
|
|
border: none;
|
|
background: #22d3ee;
|
|
color: #0f172a;
|
|
}
|
|
.flow-dialog-create:hover { opacity: 0.85; }
|
|
|
|
/* ── Responsive ── */
|
|
|
|
@media (max-width: 640px) {
|
|
.tab-label { display: none; }
|
|
.tab { padding: 4px 8px; }
|
|
.stack-view { max-height: 40vh; }
|
|
.flow-dialog { width: 240px; }
|
|
}
|
|
`;
|