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;
|
break;
|
||||||
|
|
||||||
case "full-sync":
|
case "full-sync":
|
||||||
// Server sending full document (for initial load)
|
// Server sending full document — merge to preserve local changes
|
||||||
if (msg.doc) {
|
if (msg.doc) {
|
||||||
const binary = new Uint8Array(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.#syncState = Automerge.initSyncState();
|
||||||
this.#applyDocToDOM();
|
this.#applyDocToDOM();
|
||||||
this.#scheduleSave();
|
this.#scheduleSave();
|
||||||
|
|
@ -839,20 +840,29 @@ export class CommunitySync extends EventTarget {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save current state immediately. Call from beforeunload handler.
|
* Save current state immediately. Call from beforeunload handler.
|
||||||
|
* Uses synchronous localStorage fallback since IndexedDB may not complete.
|
||||||
*/
|
*/
|
||||||
saveBeforeUnload(): void {
|
saveBeforeUnload(): void {
|
||||||
if (!this.#offlineStore) return;
|
|
||||||
|
|
||||||
if (this.#saveDebounceTimer) {
|
if (this.#saveDebounceTimer) {
|
||||||
clearTimeout(this.#saveDebounceTimer);
|
clearTimeout(this.#saveDebounceTimer);
|
||||||
this.#saveDebounceTimer = null;
|
this.#saveDebounceTimer = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.#offlineStore.saveDocImmediate(
|
const binary = Automerge.save(this.#doc);
|
||||||
this.#communitySlug,
|
|
||||||
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) {
|
} catch (e) {
|
||||||
console.warn("[CommunitySync] Failed to save before unload:", e);
|
console.warn("[CommunitySync] Failed to save before unload:", e);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -87,13 +87,58 @@ export class OfflineStore {
|
||||||
}
|
}
|
||||||
this.#pendingSaves.delete(slug);
|
this.#pendingSaves.delete(slug);
|
||||||
|
|
||||||
|
// Belt-and-suspenders: also write to localStorage synchronously
|
||||||
|
this.saveDocEmergency(slug, docBinary);
|
||||||
|
|
||||||
await this.#writeDoc(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.
|
* Load cached Automerge document binary.
|
||||||
|
* Checks for emergency localStorage save first, migrates it to IndexedDB if found.
|
||||||
*/
|
*/
|
||||||
async loadDoc(slug: string): Promise<Uint8Array | null> {
|
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);
|
const entry = await this.#getEntry(slug);
|
||||||
return entry?.docBinary ?? null;
|
return entry?.docBinary ?? null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,18 @@ import { Hono } from "hono";
|
||||||
import type { FlowKind, FeedDefinition } from "../lib/layer-types";
|
import type { FlowKind, FeedDefinition } from "../lib/layer-types";
|
||||||
export type { 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.
|
* The contract every rSpace module must implement.
|
||||||
*
|
*
|
||||||
|
|
@ -33,6 +45,8 @@ export interface RSpaceModule {
|
||||||
onSpaceDelete?: (spaceSlug: string) => Promise<void>;
|
onSpaceDelete?: (spaceSlug: string) => Promise<void>;
|
||||||
/** If true, module is hidden from app switcher (still has routes) */
|
/** If true, module is hidden from app switcher (still has routes) */
|
||||||
hidden?: boolean;
|
hidden?: boolean;
|
||||||
|
/** Browsable content types this module produces */
|
||||||
|
outputPaths?: OutputPath[];
|
||||||
/** Optional: render rich landing page body HTML */
|
/** Optional: render rich landing page body HTML */
|
||||||
landingPage?: () => string;
|
landingPage?: () => string;
|
||||||
/** Optional: external app to embed via iframe when ?view=app */
|
/** Optional: external app to embed via iframe when ?view=app */
|
||||||
|
|
@ -74,6 +88,7 @@ export interface ModuleInfo {
|
||||||
url: string;
|
url: string;
|
||||||
name: string;
|
name: string;
|
||||||
};
|
};
|
||||||
|
outputPaths?: OutputPath[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getModuleInfoList(): ModuleInfo[] {
|
export function getModuleInfoList(): ModuleInfo[] {
|
||||||
|
|
@ -89,5 +104,6 @@ export function getModuleInfoList(): ModuleInfo[] {
|
||||||
...(m.acceptsFeeds ? { acceptsFeeds: m.acceptsFeeds } : {}),
|
...(m.acceptsFeeds ? { acceptsFeeds: m.acceptsFeeds } : {}),
|
||||||
...(m.landingPage ? { hasLandingPage: true } : {}),
|
...(m.landingPage ? { hasLandingPage: true } : {}),
|
||||||
...(m.externalApp ? { externalApp: m.externalApp } : {}),
|
...(m.externalApp ? { externalApp: m.externalApp } : {}),
|
||||||
|
...(m.outputPaths ? { outputPaths: m.outputPaths } : {}),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -255,7 +255,7 @@ export class TabCache {
|
||||||
private updateCanvasLayout(moduleId: string): void {
|
private updateCanvasLayout(moduleId: string): void {
|
||||||
const app = document.getElementById("app");
|
const app = document.getElementById("app");
|
||||||
if (!app) return;
|
if (!app) return;
|
||||||
if (moduleId === "canvas") {
|
if (moduleId === "rspace") {
|
||||||
app.classList.add("canvas-layout");
|
app.classList.add("canvas-layout");
|
||||||
} else {
|
} else {
|
||||||
app.classList.remove("canvas-layout");
|
app.classList.remove("canvas-layout");
|
||||||
|
|
|
||||||
|
|
@ -858,8 +858,8 @@
|
||||||
<body data-theme="dark">
|
<body data-theme="dark">
|
||||||
<header class="rstack-header" data-theme="dark">
|
<header class="rstack-header" data-theme="dark">
|
||||||
<div class="rstack-header__left">
|
<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>
|
<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="canvas"></rstack-app-switcher>
|
<rstack-app-switcher current="rspace"></rstack-app-switcher>
|
||||||
<rstack-space-switcher current="" name=""></rstack-space-switcher>
|
<rstack-space-switcher current="" name=""></rstack-space-switcher>
|
||||||
</div>
|
</div>
|
||||||
<div class="rstack-header__center">
|
<div class="rstack-header__center">
|
||||||
|
|
@ -1125,9 +1125,13 @@
|
||||||
sw?.reload?.();
|
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 => {
|
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(() => {});
|
}).catch(() => {});
|
||||||
|
|
||||||
// ── Dark mode toggle ──
|
// ── 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");
|
const tabBar = document.querySelector("rstack-tab-bar");
|
||||||
if (tabBar) {
|
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;
|
window.__rspaceTabBar = tabBar;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1277,9 +1238,148 @@
|
||||||
spaceSwitcher.setAttribute("name", communitySlug);
|
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) {
|
if (tabBar) {
|
||||||
tabBar.setAttribute("space", communitySlug);
|
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 ──
|
// ── "Try Demo" button visibility ──
|
||||||
|
|
@ -1370,94 +1470,6 @@
|
||||||
detail: { sync, communitySlug }
|
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
|
// Initialize Presence for real-time cursors
|
||||||
const peerId = generatePeerId();
|
const peerId = generatePeerId();
|
||||||
const storedUsername = localStorage.getItem("rspace-username") || `User ${peerId.slice(0, 4)}`;
|
const storedUsername = localStorage.getItem("rspace-username") || `User ${peerId.slice(0, 4)}`;
|
||||||
|
|
@ -1837,11 +1849,18 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
shape.id = data.id;
|
shape.id = data.id;
|
||||||
shape.x = data.x ?? 100;
|
|
||||||
shape.y = data.y ?? 100;
|
// Validate coordinates — use defaults only when data is missing/invalid
|
||||||
shape.width = data.width ?? 300;
|
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);
|
||||||
shape.height = data.height ?? 200;
|
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);
|
||||||
if (data.rotation) shape.rotation = data.rotation;
|
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;
|
return shape;
|
||||||
}
|
}
|
||||||
|
|
@ -3166,6 +3185,13 @@
|
||||||
sync.saveBeforeUnload();
|
sync.saveBeforeUnload();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Mobile browsers may not fire beforeunload when backgrounding tabs
|
||||||
|
document.addEventListener("visibilitychange", () => {
|
||||||
|
if (document.visibilityState === "hidden") {
|
||||||
|
sync.saveBeforeUnload();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
sync.connect(wsUrl);
|
sync.connect(wsUrl);
|
||||||
|
|
||||||
// Debug: expose sync for console inspection
|
// Debug: expose sync for console inspection
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue