rspace-online/lib/folk-canvas.ts

547 lines
14 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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">🖼</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">▼</button>
<button class="enter-space-btn" title="Open space">↗</button>
<button class="close-btn" title="Remove">×</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 ? "▶" : "▼";
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">🖼</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 →</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,
};
}
}