547 lines
14 KiB
TypeScript
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,
|
|
};
|
|
}
|
|
}
|