diff --git a/lib/community-sync.ts b/lib/community-sync.ts index a04fe95..d20cc0e 100644 --- a/lib/community-sync.ts +++ b/lib/community-sync.ts @@ -428,27 +428,93 @@ export class CommunitySync extends EventTarget { } /** - * Delete a shape from the document + * Delete a shape from the document (hard delete — use forgetShape instead) */ deleteShape(shapeId: string): void { - this.#doc = Automerge.change(this.#doc, `Delete shape ${shapeId}`, (doc) => { + this.forgetShape(shapeId); + } + + /** + * FUN: Update — explicitly update specific fields of a shape. + * Use this for programmatic updates (API calls, module callbacks). + * Shape transform/content changes are auto-captured via registerShape(). + */ + updateShape(shapeId: string, fields: Record): void { + const existing = this.#doc.shapes?.[shapeId]; + if (!existing) return; + + this.#doc = Automerge.change(this.#doc, `Update shape ${shapeId}`, (doc) => { if (doc.shapes && doc.shapes[shapeId]) { - delete doc.shapes[shapeId]; + for (const [key, value] of Object.entries(fields)) { + (doc.shapes[shapeId] as Record)[key] = value; + } } }); - this.#shapes.delete(shapeId); + // Sync the updated shape to DOM and server + const shape = this.#shapes.get(shapeId); + if (shape) { + this.#updateShapeElement(shape, this.#doc.shapes[shapeId]); + } this.#scheduleSave(); this.#syncToServer(); } /** - * Apply full document to DOM (for initial load) + * Forget a shape — soft-delete. Shape stays in the doc but is hidden. + */ + forgetShape(shapeId: string): void { + this.#doc = Automerge.change(this.#doc, `Forget shape ${shapeId}`, (doc) => { + if (doc.shapes && doc.shapes[shapeId]) { + (doc.shapes[shapeId] as Record).forgotten = true; + (doc.shapes[shapeId] as Record).forgottenAt = Date.now(); + } + }); + + // Remove from visible DOM + this.#removeShapeFromDOM(shapeId); + this.#scheduleSave(); + this.#syncToServer(); + } + + /** + * Remember a forgotten shape — restore it to the canvas. + */ + rememberShape(shapeId: string): void { + const shapeData = this.#doc.shapes?.[shapeId]; + if (!shapeData) return; + + this.#doc = Automerge.change(this.#doc, `Remember shape ${shapeId}`, (doc) => { + if (doc.shapes && doc.shapes[shapeId]) { + (doc.shapes[shapeId] as Record).forgotten = false; + (doc.shapes[shapeId] as Record).forgottenAt = 0; + (doc.shapes[shapeId] as Record).forgottenBy = ''; + } + }); + + // Re-add to DOM + this.#applyShapeToDOM(this.#doc.shapes[shapeId]); + this.#scheduleSave(); + this.#syncToServer(); + } + + /** + * Get all forgotten shapes (for the memory layer UI). + */ + getForgottenShapes(): ShapeData[] { + const shapes = this.#doc.shapes || {}; + return Object.values(shapes).filter(s => s.forgotten); + } + + /** + * Apply full document to DOM (for initial load). + * Skips forgotten shapes — they live in the doc but are hidden from view. */ #applyDocToDOM(): void { const shapes = this.#doc.shapes || {}; for (const [id, shapeData] of Object.entries(shapes)) { + if (shapeData.forgotten) continue; // FUN: forgotten shapes stay in doc, hidden from canvas this.#applyShapeToDOM(shapeData); } @@ -456,7 +522,9 @@ export class CommunitySync extends EventTarget { } /** - * Apply Automerge patches to DOM + * Apply Automerge patches to DOM. + * Handles forgotten state: when a shape becomes forgotten, remove it from + * the visible canvas; when remembered, re-add it. */ #applyPatchesToDOM(patches: Automerge.Patch[]): void { for (const patch of patches) { @@ -468,13 +536,18 @@ export class CommunitySync extends EventTarget { const shapeData = this.#doc.shapes?.[shapeId]; if (patch.action === "del" && path.length === 2) { - // Shape deleted + // Shape hard-deleted this.#removeShapeFromDOM(shapeId); } else if (shapeData) { - // Shape created or updated - this.#applyShapeToDOM(shapeData); - // Broadcast to parent frame - this.#postMessageToParent("shape-updated", shapeData); + // FUN: if shape was just forgotten, remove from DOM + if (shapeData.forgotten) { + this.#removeShapeFromDOM(shapeId); + this.dispatchEvent(new CustomEvent("shape-forgotten", { detail: { shapeId, data: shapeData } })); + } else { + // Shape created, updated, or remembered — render it + this.#applyShapeToDOM(shapeData); + this.#postMessageToParent("shape-updated", shapeData); + } } } } @@ -487,11 +560,11 @@ export class CommunitySync extends EventTarget { let shape = this.#shapes.get(shapeData.id); if (!shape) { - // Create new shape element - shape = this.#createShapeElement(shapeData); + // FUN: New — instantiate shape element + shape = this.#newShapeElement(shapeData); if (shape) { this.#shapes.set(shapeData.id, shape); - this.dispatchEvent(new CustomEvent("shape-created", { detail: { shape, data: shapeData } })); + this.dispatchEvent(new CustomEvent("shape-new", { detail: { shape, data: shapeData } })); } return; } @@ -501,11 +574,10 @@ export class CommunitySync extends EventTarget { } /** - * Create a new shape element from data + * FUN: New — emit event for the canvas to instantiate a new shape from data */ - #createShapeElement(data: ShapeData): FolkShape | undefined { - // This will be handled by the canvas - emit event for canvas to create - this.dispatchEvent(new CustomEvent("create-shape", { detail: data })); + #newShapeElement(data: ShapeData): FolkShape | undefined { + this.dispatchEvent(new CustomEvent("new-shape", { detail: data })); return undefined; } @@ -641,13 +713,13 @@ export class CommunitySync extends EventTarget { } /** - * Remove shape from DOM + * FUN: Forget — remove shape from visible DOM (shape remains in Automerge doc) */ #removeShapeFromDOM(shapeId: string): void { const shape = this.#shapes.get(shapeId); if (shape) { this.#shapes.delete(shapeId); - this.dispatchEvent(new CustomEvent("shape-deleted", { detail: { shapeId, shape } })); + this.dispatchEvent(new CustomEvent("shape-removed", { detail: { shapeId, shape } })); } } diff --git a/server/community-store.ts b/server/community-store.ts index 78b2e5a..d2c3f8e 100644 --- a/server/community-store.ts +++ b/server/community-store.ts @@ -436,6 +436,45 @@ export function deleteShape(slug: string, shapeId: string): void { } } +/** + * Forget a shape — soft-delete. Shape stays in the doc but is hidden from view. + * Can be restored with rememberShape(). + */ +export function forgetShape(slug: string, shapeId: string, forgottenBy?: string): void { + const doc = communities.get(slug); + if (!doc || !doc.shapes?.[shapeId]) return; + + const newDoc = Automerge.change(doc, `Forget shape ${shapeId}`, (d) => { + if (d.shapes[shapeId]) { + (d.shapes[shapeId] as Record).forgotten = true; + (d.shapes[shapeId] as Record).forgottenAt = Date.now(); + if (forgottenBy) { + (d.shapes[shapeId] as Record).forgottenBy = forgottenBy; + } + } + }); + communities.set(slug, newDoc); + saveCommunity(slug); +} + +/** + * Remember a forgotten shape — restore it to the canvas. + */ +export function rememberShape(slug: string, shapeId: string): void { + const doc = communities.get(slug); + if (!doc || !doc.shapes?.[shapeId]) return; + + const newDoc = Automerge.change(doc, `Remember shape ${shapeId}`, (d) => { + if (d.shapes[shapeId]) { + (d.shapes[shapeId] as Record).forgotten = false; + (d.shapes[shapeId] as Record).forgottenAt = 0; + (d.shapes[shapeId] as Record).forgottenBy = ''; + } + }); + communities.set(slug, newDoc); + saveCommunity(slug); +} + /** * Update specific fields of a shape (for bidirectional module sync callbacks). * Only updates the fields provided, preserving all other shape data. diff --git a/server/index.ts b/server/index.ts index 547eaa1..fddc933 100644 --- a/server/index.ts +++ b/server/index.ts @@ -6,6 +6,8 @@ import { communityExists, createCommunity, deleteShape, + forgetShape, + rememberShape, generateSyncMessageForPeer, getDocumentData, loadCommunity, @@ -393,10 +395,25 @@ const server = Bun.serve({ ws.send(JSON.stringify({ type: "error", message: "Authentication required to delete" })); return; } - deleteShape(communitySlug, msg.id); - // Broadcast JSON snapshot to other json-mode clients + // FUN model: "delete" now means "forget" (soft-delete) + forgetShape(communitySlug, msg.id, ws.data.claims?.sub); + broadcastJsonSnapshot(communitySlug, peerId); + broadcastAutomergeSync(communitySlug, peerId); + } else if (msg.type === "forget" && msg.id) { + if (ws.data.readOnly) { + ws.send(JSON.stringify({ type: "error", message: "Authentication required to forget" })); + return; + } + forgetShape(communitySlug, msg.id, ws.data.claims?.sub); + broadcastJsonSnapshot(communitySlug, peerId); + broadcastAutomergeSync(communitySlug, peerId); + } else if (msg.type === "remember" && msg.id) { + if (ws.data.readOnly) { + ws.send(JSON.stringify({ type: "error", message: "Authentication required to remember" })); + return; + } + rememberShape(communitySlug, msg.id); broadcastJsonSnapshot(communitySlug, peerId); - // Broadcast Automerge sync to automerge-mode clients broadcastAutomergeSync(communitySlug, peerId); } } catch (e) { diff --git a/website/canvas.html b/website/canvas.html index 559c3b6..022b027 100644 --- a/website/canvas.html +++ b/website/canvas.html @@ -124,6 +124,119 @@ 50% { opacity: 0.5; } } + /* Memory layer panel */ + #memory-panel { + position: fixed; + top: 72px; + right: 16px; + width: 300px; + max-height: calc(100vh - 120px); + background: white; + border-radius: 12px; + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.18); + z-index: 1001; + display: none; + overflow: hidden; + } + + #memory-panel.open { + display: flex; + flex-direction: column; + } + + #memory-panel-header { + padding: 12px 16px; + border-bottom: 1px solid #e2e8f0; + display: flex; + align-items: center; + justify-content: space-between; + flex-shrink: 0; + } + + #memory-panel-header h3 { + font-size: 14px; + color: #0f172a; + margin: 0; + } + + #memory-panel-header .count { + font-size: 12px; + color: #94a3b8; + background: #f1f5f9; + padding: 2px 8px; + border-radius: 10px; + } + + #memory-list { + overflow-y: auto; + flex: 1; + padding: 8px; + } + + #memory-list:empty::after { + content: "Nothing forgotten yet"; + display: block; + text-align: center; + padding: 24px 16px; + color: #94a3b8; + font-size: 13px; + } + + .memory-item { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + border-radius: 8px; + cursor: pointer; + transition: background 0.15s; + } + + .memory-item:hover { + background: #f1f5f9; + } + + .memory-item .icon { + font-size: 18px; + width: 28px; + text-align: center; + flex-shrink: 0; + } + + .memory-item .info { + flex: 1; + min-width: 0; + } + + .memory-item .info .name { + font-size: 13px; + color: #1e293b; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .memory-item .info .meta { + font-size: 11px; + color: #94a3b8; + } + + .memory-item .remember-btn { + padding: 4px 10px; + border: none; + border-radius: 6px; + background: #14b8a6; + color: white; + font-size: 12px; + cursor: pointer; + flex-shrink: 0; + transition: background 0.15s; + } + + .memory-item .remember-btn:hover { + background: #0d9488; + } + #canvas { width: 100%; height: 100%; @@ -222,38 +335,47 @@
- - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+

💭 Memory

+ 0 +
+
+
+
Connecting... @@ -463,8 +585,8 @@ console.log("[Canvas] Initial sync complete:", e.detail.shapes); }); - // Handle shape creation from remote - sync.addEventListener("create-shape", (e) => { + // FUN: New — handle new shape from remote sync + sync.addEventListener("new-shape", (e) => { const data = e.detail; // Check if shape already exists @@ -474,7 +596,7 @@ try { isProcessingRemote = true; - const shape = createShapeElement(data); + const shape = newShapeElement(data); if (shape) { setupShapeEventListeners(shape); canvas.appendChild(shape); @@ -487,8 +609,8 @@ } }); - // Handle shape deletion from remote - sync.addEventListener("shape-deleted", (e) => { + // FUN: Forget — handle shape removal from remote sync + sync.addEventListener("shape-removed", (e) => { const { shapeId, shape } = e.detail; if (shape && shape.parentNode) { shape.remove(); @@ -496,7 +618,7 @@ }); // Create a shape element from data - function createShapeElement(data) { + function newShapeElement(data) { let shape; switch (data.type) { @@ -765,7 +887,7 @@ } // Create a shape, position it at viewport center, add to canvas, and register for sync - function createAndAddShape(tagName, props = {}) { + function newShape(tagName, props = {}) { const id = `shape-${Date.now()}-${++shapeCounter}`; const defaults = SHAPE_DEFAULTS[tagName] || { width: 300, height: 200 }; @@ -796,14 +918,14 @@ } // Toolbar button handlers - document.getElementById("add-markdown").addEventListener("click", () => { - createAndAddShape("folk-markdown", { content: "# New Note\n\nStart typing..." }); + document.getElementById("new-markdown").addEventListener("click", () => { + newShape("folk-markdown", { content: "# New Note\n\nStart typing..." }); }); - document.getElementById("add-wrapper").addEventListener("click", () => { + document.getElementById("new-wrapper").addEventListener("click", () => { const colors = ["#14b8a6", "#8b5cf6", "#f59e0b", "#ef4444", "#3b82f6", "#22c55e"]; const icons = ["📋", "💡", "📌", "🔗", "📁", "⭐"]; - const shape = createAndAddShape("folk-wrapper", { + const shape = newShape("folk-wrapper", { title: "New Card", icon: icons[Math.floor(Math.random() * icons.length)], primaryColor: colors[Math.floor(Math.random() * colors.length)], @@ -817,40 +939,40 @@ } }); - document.getElementById("add-slide").addEventListener("click", () => { - createAndAddShape("folk-slide", { label: `Slide ${shapeCounter}` }); + document.getElementById("new-slide").addEventListener("click", () => { + newShape("folk-slide", { label: `Slide ${shapeCounter}` }); }); - document.getElementById("add-chat").addEventListener("click", () => { + document.getElementById("new-chat").addEventListener("click", () => { const id = `shape-${Date.now()}-${shapeCounter}`; - createAndAddShape("folk-chat", { roomId: `room-${id}` }); + newShape("folk-chat", { roomId: `room-${id}` }); }); - document.getElementById("add-piano").addEventListener("click", () => createAndAddShape("folk-piano")); - document.getElementById("add-embed").addEventListener("click", () => createAndAddShape("folk-embed")); - document.getElementById("add-calendar").addEventListener("click", () => createAndAddShape("folk-calendar")); - document.getElementById("add-map").addEventListener("click", () => createAndAddShape("folk-map")); - document.getElementById("add-image-gen").addEventListener("click", () => createAndAddShape("folk-image-gen")); - document.getElementById("add-video-gen").addEventListener("click", () => createAndAddShape("folk-video-gen")); - document.getElementById("add-prompt").addEventListener("click", () => createAndAddShape("folk-prompt")); - document.getElementById("add-transcription").addEventListener("click", () => createAndAddShape("folk-transcription")); - document.getElementById("add-video-chat").addEventListener("click", () => createAndAddShape("folk-video-chat")); - document.getElementById("add-obs-note").addEventListener("click", () => createAndAddShape("folk-obs-note")); - document.getElementById("add-workflow").addEventListener("click", () => createAndAddShape("folk-workflow-block")); - document.getElementById("add-google-item").addEventListener("click", () => { - createAndAddShape("folk-google-item", { service: "drive", title: "New Google Item" }); + document.getElementById("new-piano").addEventListener("click", () => newShape("folk-piano")); + document.getElementById("new-embed").addEventListener("click", () => newShape("folk-embed")); + document.getElementById("new-calendar").addEventListener("click", () => newShape("folk-calendar")); + document.getElementById("new-map").addEventListener("click", () => newShape("folk-map")); + document.getElementById("new-image-gen").addEventListener("click", () => newShape("folk-image-gen")); + document.getElementById("new-video-gen").addEventListener("click", () => newShape("folk-video-gen")); + document.getElementById("new-prompt").addEventListener("click", () => newShape("folk-prompt")); + document.getElementById("new-transcription").addEventListener("click", () => newShape("folk-transcription")); + document.getElementById("new-video-chat").addEventListener("click", () => newShape("folk-video-chat")); + document.getElementById("new-obs-note").addEventListener("click", () => newShape("folk-obs-note")); + document.getElementById("new-workflow").addEventListener("click", () => newShape("folk-workflow-block")); + document.getElementById("new-google-item").addEventListener("click", () => { + newShape("folk-google-item", { service: "drive", title: "New Google Item" }); }); // Trip planning components - document.getElementById("add-itinerary").addEventListener("click", () => createAndAddShape("folk-itinerary")); - document.getElementById("add-destination").addEventListener("click", () => createAndAddShape("folk-destination")); - document.getElementById("add-budget").addEventListener("click", () => createAndAddShape("folk-budget")); - document.getElementById("add-packing-list").addEventListener("click", () => createAndAddShape("folk-packing-list")); - document.getElementById("add-booking").addEventListener("click", () => createAndAddShape("folk-booking")); + document.getElementById("new-itinerary").addEventListener("click", () => newShape("folk-itinerary")); + document.getElementById("new-destination").addEventListener("click", () => newShape("folk-destination")); + document.getElementById("new-budget").addEventListener("click", () => newShape("folk-budget")); + document.getElementById("new-packing-list").addEventListener("click", () => newShape("folk-packing-list")); + document.getElementById("new-booking").addEventListener("click", () => newShape("folk-booking")); // Token creation - creates a mint + ledger pair with connecting arrow - document.getElementById("add-token").addEventListener("click", () => { - const mint = createAndAddShape("folk-token-mint", { + document.getElementById("new-token").addEventListener("click", () => { + const mint = newShape("folk-token-mint", { tokenName: "New Token", tokenSymbol: "TKN", totalSupply: 1000, @@ -859,7 +981,7 @@ createdAt: new Date().toISOString(), }); if (mint) { - const ledger = createAndAddShape("folk-token-ledger", { + const ledger = newShape("folk-token-ledger", { mintId: mint.id, entries: [], }); @@ -881,8 +1003,8 @@ }); // Decision/choice components - document.getElementById("add-choice-vote").addEventListener("click", () => { - createAndAddShape("folk-choice-vote", { + document.getElementById("new-choice-vote").addEventListener("click", () => { + newShape("folk-choice-vote", { title: "Quick Poll", options: [ { id: "opt-1", label: "Option A", color: "#3b82f6" }, @@ -895,8 +1017,8 @@ }); }); - document.getElementById("add-choice-rank").addEventListener("click", () => { - createAndAddShape("folk-choice-rank", { + document.getElementById("new-choice-rank").addEventListener("click", () => { + newShape("folk-choice-rank", { title: "Rank These", options: [ { id: "opt-1", label: "Option A" }, @@ -907,8 +1029,8 @@ }); }); - document.getElementById("add-choice-spider").addEventListener("click", () => { - createAndAddShape("folk-choice-spider", { + document.getElementById("new-choice-spider").addEventListener("click", () => { + newShape("folk-choice-spider", { title: "Evaluate Options", options: [ { id: "opt-1", label: "Option A" }, @@ -925,8 +1047,8 @@ }); // Social media post - document.getElementById("add-social-post").addEventListener("click", () => { - createAndAddShape("folk-social-post", { + document.getElementById("new-social-post").addEventListener("click", () => { + newShape("folk-social-post", { platform: "x", postType: "text", content: "Write your post content here...", @@ -938,11 +1060,11 @@ // Arrow connection mode let connectMode = false; let connectSource = null; - const addArrowBtn = document.getElementById("add-arrow"); + const newArrowBtn = document.getElementById("new-arrow"); - addArrowBtn.addEventListener("click", () => { + newArrowBtn.addEventListener("click", () => { connectMode = !connectMode; - addArrowBtn.classList.toggle("active", connectMode); + newArrowBtn.classList.toggle("active", connectMode); canvas.classList.toggle("connect-mode", connectMode); if (!connectMode && connectSource) { @@ -982,11 +1104,96 @@ connectSource.classList.remove("connect-source"); connectSource = null; connectMode = false; - addArrowBtn.classList.remove("active"); + newArrowBtn.classList.remove("active"); canvas.classList.remove("connect-mode"); } }); + // Memory panel — browse and remember forgotten shapes + const memoryPanel = document.getElementById("memory-panel"); + const memoryList = document.getElementById("memory-list"); + const memoryCount = document.getElementById("memory-count"); + const toggleMemoryBtn = document.getElementById("toggle-memory"); + + const SHAPE_ICONS = { + "folk-markdown": "📝", "folk-wrapper": "🗂️", "folk-slide": "🎞️", + "folk-chat": "💬", "folk-piano": "🎹", "folk-embed": "🔗", + "folk-google-item": "📎", "folk-calendar": "📅", "folk-map": "🗺️", + "folk-image-gen": "🎨", "folk-video-gen": "🎬", "folk-prompt": "🤖", + "folk-transcription": "🎤", "folk-video-chat": "📹", "folk-obs-note": "📓", + "folk-workflow-block": "⚙️", "folk-itinerary": "🗓️", "folk-destination": "📍", + "folk-budget": "💰", "folk-packing-list": "🎒", "folk-booking": "✈️", + "folk-token-mint": "🪙", "folk-token-ledger": "📒", + "folk-choice-vote": "☑", "folk-choice-rank": "📊", + "folk-choice-spider": "🕸", "folk-social-post": "📱", + "folk-arrow": "↗️", + }; + + function getShapeLabel(data) { + return data.title || data.content?.slice(0, 40) || data.tokenName || data.label || data.type || "Shape"; + } + + function renderMemoryPanel() { + const forgotten = sync.getForgottenShapes(); + memoryCount.textContent = forgotten.length; + + memoryList.innerHTML = ""; + for (const shape of forgotten) { + const item = document.createElement("div"); + item.className = "memory-item"; + + const ago = shape.forgottenAt + ? timeAgo(shape.forgottenAt) + : ""; + + item.innerHTML = ` + ${SHAPE_ICONS[shape.type] || "📦"} +
+
${escapeHtml(getShapeLabel(shape))}
+
${shape.type}${ago ? " · " + ago : ""}
+
+ + `; + + item.querySelector(".remember-btn").addEventListener("click", (e) => { + e.stopPropagation(); + sync.rememberShape(shape.id); + renderMemoryPanel(); + }); + + memoryList.appendChild(item); + } + } + + function timeAgo(ts) { + const diff = Date.now() - ts; + if (diff < 60000) return "just now"; + if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`; + if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`; + return `${Math.floor(diff / 86400000)}d ago`; + } + + function escapeHtml(str) { + const d = document.createElement("div"); + d.textContent = str; + return d.innerHTML; + } + + toggleMemoryBtn.addEventListener("click", () => { + const isOpen = memoryPanel.classList.toggle("open"); + toggleMemoryBtn.classList.toggle("active", isOpen); + if (isOpen) renderMemoryPanel(); + }); + + // Refresh panel when shapes are forgotten/remembered via remote sync + sync.addEventListener("shape-forgotten", () => { + if (memoryPanel.classList.contains("open")) renderMemoryPanel(); + }); + + sync.addEventListener("synced", () => { + if (memoryPanel.classList.contains("open")) renderMemoryPanel(); + }); + // Zoom and pan controls let scale = 1; let panX = 0;