rspace-online/lib/folk-canvas.ts

547 lines
14 KiB
TypeScript

import { FolkShape } from "./folk-shape";
import { css, html } from "./tags";
import type { ShapeData, SpaceRef, NestPermissions } from "./community-sync";
const styles = css`
:host {
background: #f8fafc;
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
min-width: 300px;
min-height: 200px;
overflow: hidden;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 12px;
background: #334155;
color: white;
border-radius: 8px 8px 0 0;
font-size: 12px;
font-weight: 600;
cursor: move;
gap: 8px;
}
.header-left {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.header-left .icon {
flex-shrink: 0;
}
.source-name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.header-right {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.badge {
font-size: 10px;
padding: 2px 6px;
border-radius: 9999px;
font-weight: 500;
white-space: nowrap;
}
.badge-read {
background: rgba(59, 130, 246, 0.2);
color: #93c5fd;
}
.badge-write {
background: rgba(34, 197, 94, 0.2);
color: #86efac;
}
.header-actions button {
background: transparent;
border: none;
color: white;
cursor: pointer;
padding: 2px 6px;
border-radius: 4px;
font-size: 14px;
line-height: 1;
}
.header-actions button:hover {
background: rgba(255, 255, 255, 0.2);
}
.content {
width: 100%;
height: calc(100% - 32px);
position: relative;
overflow: auto;
}
.nested-canvas {
position: relative;
width: 100%;
height: 100%;
min-height: 150px;
}
.nested-shape {
position: absolute;
background: white;
border: 1px solid #e2e8f0;
border-radius: 6px;
padding: 8px;
font-size: 12px;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
}
.nested-shape .shape-type {
font-size: 10px;
color: #94a3b8;
margin-bottom: 4px;
}
.nested-shape .shape-content {
color: #334155;
word-break: break-word;
}
.status-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 12px;
background: #f1f5f9;
border-top: 1px solid #e2e8f0;
font-size: 10px;
color: #64748b;
}
.status-indicator {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
margin-right: 4px;
}
.status-connected { background: #22c55e; }
.status-connecting { background: #eab308; }
.status-disconnected { background: #ef4444; }
.collapsed-view {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: calc(100% - 32px);
padding: 16px;
text-align: center;
gap: 8px;
}
.collapsed-icon {
font-size: 32px;
opacity: 0.5;
}
.collapsed-label {
font-size: 13px;
color: #475569;
font-weight: 500;
}
.collapsed-meta {
font-size: 11px;
color: #94a3b8;
}
.enter-btn {
margin-top: 8px;
background: #334155;
color: white;
border: none;
border-radius: 6px;
padding: 6px 16px;
cursor: pointer;
font-size: 12px;
}
.enter-btn:hover {
background: #1e293b;
}
`;
declare global {
interface HTMLElementTagNameMap {
"folk-canvas": FolkCanvas;
}
}
export class FolkCanvas extends FolkShape {
static override tagName = "folk-canvas";
static {
const sheet = new CSSStyleSheet();
const parentRules = Array.from(FolkShape.styles.cssRules)
.map((r) => r.cssText)
.join("\n");
const childRules = Array.from(styles.cssRules)
.map((r) => r.cssText)
.join("\n");
sheet.replaceSync(`${parentRules}\n${childRules}`);
this.styles = sheet;
}
#sourceSlug: string = "";
#parentSlug: string = ""; // slug of the space this shape lives in (for nest-from context)
#permissions: NestPermissions = {
read: true, write: false, addShapes: false, deleteShapes: false, reshare: false
};
#collapsed = false;
#label: string | null = null;
#sourceDID: string | null = null;
// WebSocket connection to nested space
#ws: WebSocket | null = null;
#connectionStatus: "disconnected" | "connecting" | "connected" = "disconnected";
#nestedShapes: Map<string, ShapeData> = new Map();
#reconnectAttempts = 0;
#maxReconnectAttempts = 5;
// DOM refs
#nestedCanvasEl: HTMLElement | null = null;
#statusIndicator: HTMLElement | null = null;
#statusText: HTMLElement | null = null;
#shapeCountEl: HTMLElement | null = null;
get sourceSlug() { return this.#sourceSlug; }
set sourceSlug(value: string) {
if (this.#sourceSlug === value) return;
this.#sourceSlug = value;
this.requestUpdate("sourceSlug");
this.dispatchEvent(new CustomEvent("content-change", { detail: { sourceSlug: value } }));
// Reconnect to new source
this.#disconnect();
if (value) this.#connectToSource();
}
get permissions(): NestPermissions { return this.#permissions; }
set permissions(value: NestPermissions) {
this.#permissions = value;
this.requestUpdate("permissions");
}
get collapsed() { return this.#collapsed; }
set collapsed(value: boolean) {
this.#collapsed = value;
this.requestUpdate("collapsed");
this.#renderView();
}
get label() { return this.#label; }
set label(value: string | null) {
this.#label = value;
this.requestUpdate("label");
}
get sourceDID() { return this.#sourceDID; }
set sourceDID(value: string | null) { this.#sourceDID = value; }
get parentSlug() { return this.#parentSlug; }
set parentSlug(value: string) { this.#parentSlug = value; }
override createRenderRoot() {
const root = super.createRenderRoot();
// Read initial attributes
this.#sourceSlug = this.getAttribute("source-slug") || "";
this.#label = this.getAttribute("label") || null;
this.#collapsed = this.getAttribute("collapsed") === "true";
const wrapper = document.createElement("div");
wrapper.style.cssText = "width: 100%; height: 100%; display: flex; flex-direction: column;";
wrapper.innerHTML = html`
<div class="header" data-drag>
<div class="header-left">
<span class="icon">\u{1F5BC}</span>
<span class="source-name"></span>
</div>
<div class="header-right">
<span class="permission-badge badge"></span>
<div class="header-actions">
<button class="collapse-btn" title="Toggle collapse">\u25BC</button>
<button class="enter-space-btn" title="Open space">\u2197</button>
<button class="close-btn" title="Remove">\u00D7</button>
</div>
</div>
</div>
<div class="content">
<div class="nested-canvas"></div>
</div>
<div class="status-bar">
<span><span class="status-indicator status-disconnected"></span><span class="status-text">Disconnected</span></span>
<span class="shape-count">0 shapes</span>
</div>
`;
// Replace the slot container
const slot = root.querySelector("slot");
const containerDiv = slot?.parentElement as HTMLElement;
if (containerDiv) containerDiv.replaceWith(wrapper);
// Cache DOM refs
this.#nestedCanvasEl = wrapper.querySelector(".nested-canvas");
this.#statusIndicator = wrapper.querySelector(".status-indicator");
this.#statusText = wrapper.querySelector(".status-text");
this.#shapeCountEl = wrapper.querySelector(".shape-count");
const sourceNameEl = wrapper.querySelector(".source-name") as HTMLElement;
const permBadge = wrapper.querySelector(".permission-badge") as HTMLElement;
const collapseBtn = wrapper.querySelector(".collapse-btn") as HTMLButtonElement;
const enterBtn = wrapper.querySelector(".enter-space-btn") as HTMLButtonElement;
const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement;
// Set header text
sourceNameEl.textContent = this.#label || this.#sourceSlug || "Nested Space";
// Permission badge
if (this.#permissions.write) {
permBadge.textContent = "read + write";
permBadge.className = "badge badge-write";
} else {
permBadge.textContent = "read-only";
permBadge.className = "badge badge-read";
}
// Collapse toggle
collapseBtn.addEventListener("click", (e) => {
e.stopPropagation();
this.collapsed = !this.#collapsed;
collapseBtn.textContent = this.#collapsed ? "\u25B6" : "\u25BC";
this.dispatchEvent(new CustomEvent("content-change", { detail: { collapsed: this.#collapsed } }));
});
// Enter space (navigate)
enterBtn.addEventListener("click", (e) => {
e.stopPropagation();
if (this.#sourceSlug) {
window.open(`/${this.#sourceSlug}/canvas`, "_blank");
}
});
// Close (remove nesting)
closeBtn.addEventListener("click", (e) => {
e.stopPropagation();
this.dispatchEvent(new CustomEvent("close"));
});
// Prevent drag on interactive content
const content = wrapper.querySelector(".content") as HTMLElement;
content.addEventListener("pointerdown", (e) => e.stopPropagation());
// Connect to the nested space
if (this.#sourceSlug && !this.#collapsed) {
this.#connectToSource();
}
return root;
}
#connectToSource(): void {
if (!this.#sourceSlug || this.#connectionStatus === "connected" || this.#connectionStatus === "connecting") return;
this.#setStatus("connecting");
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const nestParam = this.#parentSlug ? `&nest-from=${encodeURIComponent(this.#parentSlug)}` : "";
const wsUrl = `${protocol}//${window.location.host}/ws/${this.#sourceSlug}?mode=json${nestParam}`;
this.#ws = new WebSocket(wsUrl);
this.#ws.onopen = () => {
this.#connectionStatus = "connected";
this.#reconnectAttempts = 0;
this.#setStatus("connected");
};
this.#ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
if (msg.type === "snapshot" && msg.shapes) {
this.#nestedShapes.clear();
for (const [id, shape] of Object.entries(msg.shapes)) {
const s = shape as ShapeData;
if (!s.forgotten) {
this.#nestedShapes.set(id, s);
}
}
this.#renderNestedShapes();
}
} catch (e) {
console.error("[FolkCanvas] Failed to handle message:", e);
}
};
this.#ws.onclose = () => {
this.#connectionStatus = "disconnected";
this.#setStatus("disconnected");
this.#attemptReconnect();
};
this.#ws.onerror = () => {
this.#setStatus("disconnected");
};
}
#attemptReconnect(): void {
if (this.#reconnectAttempts >= this.#maxReconnectAttempts) return;
this.#reconnectAttempts++;
const delay = Math.min(1000 * Math.pow(2, this.#reconnectAttempts - 1), 16000);
setTimeout(() => {
if (this.#connectionStatus === "disconnected") {
this.#connectToSource();
}
}, delay);
}
#disconnect(): void {
if (this.#ws) {
this.#ws.onclose = null; // prevent reconnect
this.#ws.close();
this.#ws = null;
}
this.#connectionStatus = "disconnected";
this.#nestedShapes.clear();
this.#setStatus("disconnected");
}
#setStatus(status: "disconnected" | "connecting" | "connected"): void {
this.#connectionStatus = status;
if (this.#statusIndicator) {
this.#statusIndicator.className = `status-indicator status-${status}`;
}
if (this.#statusText) {
this.#statusText.textContent = status.charAt(0).toUpperCase() + status.slice(1);
}
}
#renderView(): void {
if (!this.shadowRoot) return;
const content = this.shadowRoot.querySelector(".content") as HTMLElement;
const statusBar = this.shadowRoot.querySelector(".status-bar") as HTMLElement;
if (!content || !statusBar) return;
if (this.#collapsed) {
content.innerHTML = `
<div class="collapsed-view">
<div class="collapsed-icon">\u{1F5BC}</div>
<div class="collapsed-label">${this.#label || this.#sourceSlug}</div>
<div class="collapsed-meta">${this.#nestedShapes.size} shapes</div>
<button class="enter-btn">Open space \u2192</button>
</div>
`;
statusBar.style.display = "none";
const enterBtn = content.querySelector(".enter-btn");
enterBtn?.addEventListener("click", () => {
if (this.#sourceSlug) window.open(`/${this.#sourceSlug}/canvas`, "_blank");
});
// Disconnect when collapsed
this.#disconnect();
} else {
content.innerHTML = `<div class="nested-canvas"></div>`;
this.#nestedCanvasEl = content.querySelector(".nested-canvas");
statusBar.style.display = "flex";
// Reconnect when expanded
if (this.#sourceSlug) this.#connectToSource();
this.#renderNestedShapes();
}
}
#renderNestedShapes(): void {
if (!this.#nestedCanvasEl) return;
this.#nestedCanvasEl.innerHTML = "";
if (this.#nestedShapes.size === 0) {
this.#nestedCanvasEl.innerHTML = `
<div style="display:flex;align-items:center;justify-content:center;height:100%;color:#94a3b8;font-size:12px;">
${this.#connectionStatus === "connected" ? "Empty space" : "Connecting..."}
</div>
`;
}
// Calculate bounding box of all shapes to fit within our viewport
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const shape of this.#nestedShapes.values()) {
minX = Math.min(minX, shape.x);
minY = Math.min(minY, shape.y);
maxX = Math.max(maxX, shape.x + (shape.width || 300));
maxY = Math.max(maxY, shape.y + (shape.height || 200));
}
const contentWidth = maxX - minX || 1;
const contentHeight = maxY - minY || 1;
const canvasWidth = this.#nestedCanvasEl.clientWidth || 600;
const canvasHeight = this.#nestedCanvasEl.clientHeight || 400;
const scale = Math.min(canvasWidth / contentWidth, canvasHeight / contentHeight, 1) * 0.9;
const offsetX = (canvasWidth - contentWidth * scale) / 2;
const offsetY = (canvasHeight - contentHeight * scale) / 2;
for (const shape of this.#nestedShapes.values()) {
const el = document.createElement("div");
el.className = "nested-shape";
el.style.left = `${offsetX + (shape.x - minX) * scale}px`;
el.style.top = `${offsetY + (shape.y - minY) * scale}px`;
el.style.width = `${(shape.width || 300) * scale}px`;
el.style.height = `${(shape.height || 200) * scale}px`;
const typeLabel = shape.type.replace("folk-", "").replace(/-/g, " ");
const content = shape.content
? shape.content.slice(0, 100) + (shape.content.length > 100 ? "..." : "")
: "";
el.innerHTML = `
<div class="shape-type">${typeLabel}</div>
<div class="shape-content">${content}</div>
`;
this.#nestedCanvasEl.appendChild(el);
}
// Update shape count
if (this.#shapeCountEl) {
this.#shapeCountEl.textContent = `${this.#nestedShapes.size} shape${this.#nestedShapes.size !== 1 ? "s" : ""}`;
}
}
disconnectedCallback(): void {
this.#disconnect();
}
override toJSON() {
return {
...super.toJSON(),
type: "folk-canvas",
sourceSlug: this.#sourceSlug,
sourceDID: this.#sourceDID,
permissions: this.#permissions,
collapsed: this.#collapsed,
label: this.#label,
};
}
}