feat: persistent tab bar, offline save improvements, and shape validation
- Tab bar state persists to localStorage per space - Emergency synchronous localStorage fallback for beforeunload saves - Merge Automerge full-sync instead of replacing (preserves local changes) - Validate shape coordinates before applying (prevent NaN/Infinity) - Save on visibilitychange for mobile browser tab backgrounding - Add OutputPath type for module browsable content types - Fix canvas module ID from "canvas" to "rspace" in tab-cache Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
f69ee9f977
commit
a61f562bbf
|
|
@ -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<CommunityDoc>(binary);
|
||||
const serverDoc = Automerge.load<CommunityDoc>(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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Uint8Array | null> {
|
||||
// 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<void>;
|
||||
/** 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 } : {}),
|
||||
}));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -858,8 +858,8 @@
|
|||
<body data-theme="dark">
|
||||
<header class="rstack-header" data-theme="dark">
|
||||
<div class="rstack-header__left">
|
||||
<a href="/" style="display:flex;align-items:center;margin-right:4px"><img src="/logo.png" alt="rSpace" class="rstack-header__logo"></a>
|
||||
<rstack-app-switcher current="canvas"></rstack-app-switcher>
|
||||
<a href="/" style="display:flex;align-items:center;margin-right:4px"><img src="/favicon.png" alt="rSpace" class="rstack-header__logo"></a>
|
||||
<rstack-app-switcher current="rspace"></rstack-app-switcher>
|
||||
<rstack-space-switcher current="" name=""></rstack-space-switcher>
|
||||
</div>
|
||||
<div class="rstack-header__center">
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue