diff --git a/lib/community-sync.ts b/lib/community-sync.ts index 6a3e3d3..4643db9 100644 --- a/lib/community-sync.ts +++ b/lib/community-sync.ts @@ -305,10 +305,11 @@ export class CommunitySync extends EventTarget { break; case "full-sync": - // Server sending full document (for initial load) + // Server sending full document — merge to preserve local changes if (msg.doc) { const binary = new Uint8Array(msg.doc); - this.#doc = Automerge.load(binary); + const serverDoc = Automerge.load(binary); + this.#doc = Automerge.merge(this.#doc, serverDoc); this.#syncState = Automerge.initSyncState(); this.#applyDocToDOM(); this.#scheduleSave(); @@ -839,20 +840,29 @@ export class CommunitySync extends EventTarget { /** * Save current state immediately. Call from beforeunload handler. + * Uses synchronous localStorage fallback since IndexedDB may not complete. */ saveBeforeUnload(): void { - if (!this.#offlineStore) return; - if (this.#saveDebounceTimer) { clearTimeout(this.#saveDebounceTimer); this.#saveDebounceTimer = null; } try { - this.#offlineStore.saveDocImmediate( - this.#communitySlug, - Automerge.save(this.#doc) - ); + const binary = Automerge.save(this.#doc); + + // Synchronous localStorage fallback (guaranteed to complete) + if (this.#offlineStore) { + this.#offlineStore.saveDocEmergency(this.#communitySlug, binary); + } + + // Also fire off IndexedDB save (may not complete before page dies) + if (this.#offlineStore) { + this.#offlineStore.saveDocImmediate(this.#communitySlug, binary); + } + + // Push any pending Automerge changes to the server + this.#syncToServer(); } catch (e) { console.warn("[CommunitySync] Failed to save before unload:", e); } diff --git a/lib/offline-store.ts b/lib/offline-store.ts index 97b6c70..40b92e9 100644 --- a/lib/offline-store.ts +++ b/lib/offline-store.ts @@ -87,13 +87,58 @@ export class OfflineStore { } this.#pendingSaves.delete(slug); + // Belt-and-suspenders: also write to localStorage synchronously + this.saveDocEmergency(slug, docBinary); + await this.#writeDoc(slug, docBinary); } + /** + * Synchronous emergency save to localStorage. + * Used as a fallback when IndexedDB may not complete (e.g. beforeunload). + * The data is base64-encoded to fit in localStorage's string-only storage. + */ + saveDocEmergency(slug: string, docBinary: Uint8Array): void { + try { + const key = `rspace-save-${slug}`; + // Convert Uint8Array to base64 string + let binary = ""; + for (let i = 0; i < docBinary.length; i++) { + binary += String.fromCharCode(docBinary[i]); + } + const base64 = btoa(binary); + localStorage.setItem(key, base64); + } catch (e) { + // localStorage may be full or unavailable — silently fail + console.warn("[OfflineStore] Emergency save failed:", e); + } + } + /** * Load cached Automerge document binary. + * Checks for emergency localStorage save first, migrates it to IndexedDB if found. */ async loadDoc(slug: string): Promise { + // Check for emergency localStorage fallback first + const emergencyKey = `rspace-save-${slug}`; + try { + const base64 = localStorage.getItem(emergencyKey); + if (base64) { + console.log("[OfflineStore] Recovering from emergency localStorage save for", slug); + const binary = atob(base64); + const docBinary = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + docBinary[i] = binary.charCodeAt(i); + } + // Migrate to IndexedDB and clean up localStorage + await this.#writeDoc(slug, docBinary); + localStorage.removeItem(emergencyKey); + return docBinary; + } + } catch (e) { + console.warn("[OfflineStore] Failed to recover emergency save:", e); + } + const entry = await this.#getEntry(slug); return entry?.docBinary ?? null; } diff --git a/shared/module.ts b/shared/module.ts index 18b56a7..bd3b882 100644 --- a/shared/module.ts +++ b/shared/module.ts @@ -2,6 +2,18 @@ import { Hono } from "hono"; import type { FlowKind, FeedDefinition } from "../lib/layer-types"; export type { FeedDefinition } from "../lib/layer-types"; +/** A browsable content type that a module produces. */ +export interface OutputPath { + /** URL segment: "notebooks" */ + path: string; + /** Display name: "Notebooks" */ + name: string; + /** Emoji: "📓" */ + icon: string; + /** Short description: "Rich-text collaborative notebooks" */ + description: string; +} + /** * The contract every rSpace module must implement. * @@ -33,6 +45,8 @@ export interface RSpaceModule { onSpaceDelete?: (spaceSlug: string) => Promise; /** If true, module is hidden from app switcher (still has routes) */ hidden?: boolean; + /** Browsable content types this module produces */ + outputPaths?: OutputPath[]; /** Optional: render rich landing page body HTML */ landingPage?: () => string; /** Optional: external app to embed via iframe when ?view=app */ @@ -74,6 +88,7 @@ export interface ModuleInfo { url: string; name: string; }; + outputPaths?: OutputPath[]; } export function getModuleInfoList(): ModuleInfo[] { @@ -89,5 +104,6 @@ export function getModuleInfoList(): ModuleInfo[] { ...(m.acceptsFeeds ? { acceptsFeeds: m.acceptsFeeds } : {}), ...(m.landingPage ? { hasLandingPage: true } : {}), ...(m.externalApp ? { externalApp: m.externalApp } : {}), + ...(m.outputPaths ? { outputPaths: m.outputPaths } : {}), })); } diff --git a/shared/tab-cache.ts b/shared/tab-cache.ts index 9e7bb05..24cb6f4 100644 --- a/shared/tab-cache.ts +++ b/shared/tab-cache.ts @@ -255,7 +255,7 @@ export class TabCache { private updateCanvasLayout(moduleId: string): void { const app = document.getElementById("app"); if (!app) return; - if (moduleId === "canvas") { + if (moduleId === "rspace") { app.classList.add("canvas-layout"); } else { app.classList.remove("canvas-layout"); diff --git a/website/canvas.html b/website/canvas.html index f77a798..83e7659 100644 --- a/website/canvas.html +++ b/website/canvas.html @@ -858,8 +858,8 @@
- - + +
@@ -1125,9 +1125,13 @@ sw?.reload?.(); }); - // Load module list for app switcher + // Load module list for app switcher and tab bar + menu + let moduleList = []; fetch("/api/modules").then(r => r.json()).then(data => { - document.querySelector("rstack-app-switcher")?.setModules(data.modules || []); + moduleList = data.modules || []; + document.querySelector("rstack-app-switcher")?.setModules(moduleList); + const tb = document.querySelector("rstack-tab-bar"); + if (tb) tb.setModules(moduleList); }).catch(() => {}); // ── Dark mode toggle ── @@ -1151,52 +1155,9 @@ }); } - // ── Tab bar / Layer system initialization ── + // ── Tab bar reference (full init deferred until communitySlug is known) ── const tabBar = document.querySelector("rstack-tab-bar"); if (tabBar) { - const canvasDefaultLayer = { - id: "layer-canvas", - moduleId: "rspace", - label: "rSpace", - order: 0, - color: "", - visible: true, - createdAt: Date.now(), - }; - - tabBar.setLayers([canvasDefaultLayer]); - tabBar.setAttribute("active", canvasDefaultLayer.id); - - // Tab switching: navigate to the selected module's page - tabBar.addEventListener("layer-switch", (e) => { - const { moduleId } = e.detail; - if (moduleId === "rspace") return; // already on canvas - window.location.href = rspaceNavUrl( - document.querySelector("rstack-space-switcher")?.getAttribute("current") || "demo", - moduleId - ); - }); - - // Adding a new tab: navigate to that module - tabBar.addEventListener("layer-add", (e) => { - const { moduleId } = e.detail; - window.location.href = rspaceNavUrl( - document.querySelector("rstack-space-switcher")?.getAttribute("current") || "demo", - moduleId - ); - }); - - // Closing a tab - tabBar.addEventListener("layer-close", (e) => { - tabBar.removeLayer(e.detail.layerId); - }); - - // View mode toggle - tabBar.addEventListener("view-toggle", (e) => { - document.dispatchEvent(new CustomEvent("layer-view-mode", { detail: { mode: e.detail.mode } })); - }); - - // Expose for CommunitySync wiring window.__rspaceTabBar = tabBar; } @@ -1277,9 +1238,148 @@ spaceSwitcher.setAttribute("name", communitySlug); } - // Update tab bar with resolved space slug + // ── Tab bar / Layer system initialization (needs communitySlug) ── + const currentModuleId = 'rspace'; + const TABS_KEY = 'rspace_tabs_' + communitySlug; + if (tabBar) { tabBar.setAttribute("space", communitySlug); + + // Helper: look up a module's display name + function getModuleLabel(id) { + const m = moduleList.find(mod => mod.id === id); + return m ? m.name : id; + } + + // Helper: create a layer object + function makeLayer(id, order) { + return { + id: 'layer-' + id, + moduleId: id, + label: getModuleLabel(id), + order: order, + color: '', + visible: true, + createdAt: Date.now(), + }; + } + + // ── Restore tabs from localStorage ── + let layers; + try { + const saved = localStorage.getItem(TABS_KEY); + layers = saved ? JSON.parse(saved) : []; + if (!Array.isArray(layers)) layers = []; + } catch(e) { layers = []; } + + // Ensure the current module is in the tab list + if (!layers.find(l => l.moduleId === currentModuleId)) { + layers.push(makeLayer(currentModuleId, layers.length)); + } + + // Persist immediately (includes the newly-added tab) + localStorage.setItem(TABS_KEY, JSON.stringify(layers)); + + // Render all tabs with the current one active + tabBar.setLayers(layers); + tabBar.setAttribute('active', 'layer-' + currentModuleId); + + // Helper: save current tab list to localStorage + function saveTabs() { + localStorage.setItem(TABS_KEY, JSON.stringify(layers)); + } + + // ── Tab events ── + tabBar.addEventListener('layer-switch', (e) => { + const { moduleId } = e.detail; + if (moduleId === 'rspace') return; // already on canvas + saveTabs(); + window.location.href = rspaceNavUrl(communitySlug, moduleId); + }); + + tabBar.addEventListener('layer-add', (e) => { + const { moduleId } = e.detail; + if (!layers.find(l => l.moduleId === moduleId)) { + layers.push(makeLayer(moduleId, layers.length)); + } + saveTabs(); + window.location.href = rspaceNavUrl(communitySlug, moduleId); + }); + + tabBar.addEventListener('layer-close', (e) => { + const { layerId } = e.detail; + tabBar.removeLayer(layerId); + layers = layers.filter(l => l.id !== layerId); + saveTabs(); + }); + + tabBar.addEventListener('view-toggle', (e) => { + document.dispatchEvent(new CustomEvent('layer-view-mode', { detail: { mode: e.detail.mode } })); + }); + + // ── CommunitySync: merge with Automerge once connected ── + document.addEventListener('community-sync-ready', (e) => { + const sync = e.detail?.sync; + if (!sync) return; + + // Merge: Automerge layers win if they exist, otherwise seed from localStorage + const remoteLayers = sync.getLayers?.() || []; + if (remoteLayers.length > 0) { + // Ensure current module is also in the Automerge set + if (!remoteLayers.find(l => l.moduleId === currentModuleId)) { + const newLayer = makeLayer(currentModuleId, remoteLayers.length); + sync.addLayer?.(newLayer); + } + layers = sync.getLayers?.() || []; + tabBar.setLayers(layers); + const activeId = sync.doc?.activeLayerId; + if (activeId) tabBar.setAttribute('active', activeId); + if (sync.getFlows) tabBar.setFlows(sync.getFlows()); + } else { + // First connection: push all localStorage tabs into Automerge + for (const l of layers) { + sync.addLayer?.(l); + } + sync.setActiveLayer?.('layer-' + currentModuleId); + } + + // Keep localStorage in sync + saveTabs(); + + // Sync layer changes back to Automerge + tabBar.addEventListener('layer-switch', (e) => { + sync.setActiveLayer?.(e.detail.layerId); + }); + tabBar.addEventListener('layer-add', (e) => { + const { moduleId } = e.detail; + const newLayer = makeLayer(moduleId, sync.getLayers?.().length || 0); + sync.addLayer?.(newLayer); + }); + tabBar.addEventListener('layer-close', (e) => { + sync.removeLayer?.(e.detail.layerId); + }); + tabBar.addEventListener('layer-reorder', (e) => { + const { layerId, newIndex } = e.detail; + sync.updateLayer?.(layerId, { order: newIndex }); + const all = sync.getLayers?.() || []; + all.forEach((l, i) => { if (l.order !== i) sync.updateLayer?.(l.id, { order: i }); }); + }); + tabBar.addEventListener('flow-create', (e) => { sync.addFlow?.(e.detail.flow); }); + tabBar.addEventListener('flow-remove', (e) => { sync.removeFlow?.(e.detail.flowId); }); + tabBar.addEventListener('view-toggle', (e) => { sync.setLayerViewMode?.(e.detail.mode); }); + + // Listen for remote changes + sync.addEventListener('change', () => { + layers = sync.getLayers?.() || []; + tabBar.setLayers(layers); + if (sync.getFlows) tabBar.setFlows(sync.getFlows()); + const activeId = sync.doc?.activeLayerId; + if (activeId) tabBar.setAttribute('active', activeId); + const viewMode = sync.doc?.layerViewMode; + if (viewMode) tabBar.setAttribute('view-mode', viewMode); + saveTabs(); // keep localStorage in sync + }); + }); } // ── "Try Demo" button visibility ── @@ -1370,94 +1470,6 @@ detail: { sync, communitySlug } })); - // Wire tab bar to CommunitySync for layer persistence - if (tabBar && sync) { - const canvasDefaultLayer = { - id: "layer-canvas", - moduleId: "rspace", - label: "rSpace", - order: 0, - color: "", - visible: true, - createdAt: Date.now(), - }; - - // Load persisted layers from Automerge - const layers = sync.getLayers?.() || []; - if (layers.length > 0) { - tabBar.setLayers(layers); - const activeId = sync.doc?.activeLayerId; - if (activeId) tabBar.setAttribute("active", activeId); - if (sync.getFlows) tabBar.setFlows(sync.getFlows()); - } else { - // First visit: persist the canvas layer - sync.addLayer?.(canvasDefaultLayer); - sync.setActiveLayer?.(canvasDefaultLayer.id); - } - - // Persist layer switch - tabBar.addEventListener("layer-switch", (e) => { - sync.setActiveLayer?.(e.detail.layerId); - }); - - // Persist new layer - tabBar.addEventListener("layer-add", (e) => { - const { moduleId } = e.detail; - sync.addLayer?.({ - id: "layer-" + moduleId, - moduleId, - label: moduleId, - order: (sync.getLayers?.() || []).length, - color: "", - visible: true, - createdAt: Date.now(), - }); - }); - - // Persist layer close - tabBar.addEventListener("layer-close", (e) => { - sync.removeLayer?.(e.detail.layerId); - }); - - // Persist layer reorder - tabBar.addEventListener("layer-reorder", (e) => { - const { layerId, newIndex } = e.detail; - sync.updateLayer?.(layerId, { order: newIndex }); - const allLayers = sync.getLayers?.() || []; - allLayers.forEach((l, i) => { - if (l.order !== i) sync.updateLayer?.(l.id, { order: i }); - }); - }); - - // Flow creation from stack view - tabBar.addEventListener("flow-create", (e) => { - sync.addFlow?.(e.detail.flow); - }); - - // Flow removal from stack view - tabBar.addEventListener("flow-remove", (e) => { - sync.removeFlow?.(e.detail.flowId); - }); - - // View mode persistence - tabBar.addEventListener("view-toggle", (e) => { - sync.setLayerViewMode?.(e.detail.mode); - }); - - // Sync remote layer/flow changes back to tab bar - sync.addEventListener("change", () => { - const updatedLayers = sync.getLayers?.() || []; - if (updatedLayers.length > 0) { - tabBar.setLayers(updatedLayers); - if (sync.getFlows) tabBar.setFlows(sync.getFlows()); - const activeId = sync.doc?.activeLayerId; - if (activeId) tabBar.setAttribute("active", activeId); - const viewMode = sync.doc?.layerViewMode; - if (viewMode) tabBar.setAttribute("view-mode", viewMode); - } - }); - } - // Initialize Presence for real-time cursors const peerId = generatePeerId(); const storedUsername = localStorage.getItem("rspace-username") || `User ${peerId.slice(0, 4)}`; @@ -1837,11 +1849,18 @@ } shape.id = data.id; - shape.x = data.x ?? 100; - shape.y = data.y ?? 100; - shape.width = data.width ?? 300; - shape.height = data.height ?? 200; - if (data.rotation) shape.rotation = data.rotation; + + // Validate coordinates — use defaults only when data is missing/invalid + const x = (typeof data.x === "number" && Number.isFinite(data.x)) ? data.x : (console.warn(`[Canvas] Shape ${data.id}: invalid x=${data.x}, using default`), 100); + const y = (typeof data.y === "number" && Number.isFinite(data.y)) ? data.y : (console.warn(`[Canvas] Shape ${data.id}: invalid y=${data.y}, using default`), 100); + const w = (typeof data.width === "number" && Number.isFinite(data.width) && data.width > 0) ? data.width : (console.warn(`[Canvas] Shape ${data.id}: invalid width=${data.width}, using default`), 300); + const h = (typeof data.height === "number" && Number.isFinite(data.height) && data.height > 0) ? data.height : (console.warn(`[Canvas] Shape ${data.id}: invalid height=${data.height}, using default`), 200); + + shape.x = x; + shape.y = y; + shape.width = w; + shape.height = h; + if (typeof data.rotation === "number" && Number.isFinite(data.rotation)) shape.rotation = data.rotation; return shape; } @@ -3166,6 +3185,13 @@ sync.saveBeforeUnload(); }); + // Mobile browsers may not fire beforeunload when backgrounding tabs + document.addEventListener("visibilitychange", () => { + if (document.visibilityState === "hidden") { + sync.saveBeforeUnload(); + } + }); + sync.connect(wsUrl); // Debug: expose sync for console inspection