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">🖼</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,
|
||
};
|
||
}
|
||
}
|