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 = 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`
\u{1F5BC}
Disconnected 0 shapes
`; // 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 = `
\u{1F5BC}
${this.#label || this.#sourceSlug}
${this.#nestedShapes.size} shapes
`; 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 = `
`; 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 = `
${this.#connectionStatus === "connected" ? "Empty space" : "Connecting..."}
`; } // 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 = `
${typeLabel}
${content}
`; 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, }; } }