feat(shell): cross-tab sync via BroadcastChannel + keyboard shortcuts & swipe gestures
Add BroadcastChannel for instant same-browser tab sync — opening/closing tabs in one window propagates immediately to sibling tabs. Extract reconcileRemoteLayers() helper shared by BroadcastChannel and Automerge, which cleans up cached DOM panes on remote removal and handles active-tab-closed scenarios. Also adds configurable rApp shortcuts (Ctrl/Alt+1-9), header swipe gestures for rApp cycling, and body data-module-id attr for swipe context. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
8741f5dbd5
commit
df77c9c903
136
server/shell.ts
136
server/shell.ts
|
|
@ -176,7 +176,7 @@ export function renderShell(opts: ShellOptions): string {
|
||||||
<style>${SUBNAV_CSS}</style>
|
<style>${SUBNAV_CSS}</style>
|
||||||
<style>${TABBAR_CSS}</style>
|
<style>${TABBAR_CSS}</style>
|
||||||
</head>
|
</head>
|
||||||
<body data-space-visibility="${escapeAttr(spaceVisibility)}" data-space-slug="${escapeAttr(spaceSlug)}" data-scope-overrides="${escapeAttr(JSON.stringify(scopeOverrides))}">
|
<body data-space-visibility="${escapeAttr(spaceVisibility)}" data-space-slug="${escapeAttr(spaceSlug)}" data-module-id="${escapeAttr(moduleId)}" data-scope-overrides="${escapeAttr(JSON.stringify(scopeOverrides))}">
|
||||||
<header class="rstack-header">
|
<header class="rstack-header">
|
||||||
<div class="rstack-header__left">
|
<div class="rstack-header__left">
|
||||||
<a href="/" style="display:flex;align-items:center;margin-right:4px"><img src="/favicon.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>
|
||||||
|
|
@ -568,7 +568,77 @@ export function renderShell(opts: ShellOptions): string {
|
||||||
} catch(e) {}
|
} catch(e) {}
|
||||||
};
|
};
|
||||||
if (immediate) { doSave(); } else { _tabSaveTimer = setTimeout(doSave, 500); }
|
if (immediate) { doSave(); } else { _tabSaveTimer = setTimeout(doSave, 500); }
|
||||||
|
// Broadcast to other same-browser tabs
|
||||||
|
if (_tabChannel) {
|
||||||
|
_tabChannel.postMessage({
|
||||||
|
type: 'tabs-sync',
|
||||||
|
layers: layers,
|
||||||
|
closed: [..._closedModuleIds],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── BroadcastChannel: same-browser cross-tab sync ──
|
||||||
|
const _tabChannel = (() => {
|
||||||
|
try { return new BroadcastChannel('rspace_tabs_' + spaceSlug); }
|
||||||
|
catch(e) { return null; }
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Reconcile remote layer changes (shared by BroadcastChannel + Automerge)
|
||||||
|
function reconcileRemoteLayers(remoteLayers) {
|
||||||
|
const prev = new Set(layers.map(l => l.moduleId));
|
||||||
|
const next = new Set(remoteLayers.map(l => l.moduleId));
|
||||||
|
|
||||||
|
// Remove cached panes for tabs that disappeared
|
||||||
|
for (const mid of prev) {
|
||||||
|
if (!next.has(mid) && tabCache) tabCache.removePane(mid);
|
||||||
|
}
|
||||||
|
|
||||||
|
layers = deduplicateLayers(remoteLayers);
|
||||||
|
|
||||||
|
if (currentModuleId && !layers.find(l => l.moduleId === currentModuleId)) {
|
||||||
|
// Active tab was closed remotely — switch to nearest
|
||||||
|
if (layers.length > 0) {
|
||||||
|
const nearest = layers[layers.length - 1];
|
||||||
|
currentModuleId = nearest.moduleId;
|
||||||
|
tabBar.setLayers(layers);
|
||||||
|
tabBar.setAttribute('active', 'layer-' + currentModuleId);
|
||||||
|
if (tabCache) {
|
||||||
|
tabCache.switchTo(currentModuleId).then(ok => {
|
||||||
|
if (!ok) window.location.href = window.__rspaceNavUrl(spaceSlug, currentModuleId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No tabs left — show dashboard
|
||||||
|
tabBar.setLayers([]);
|
||||||
|
tabBar.setAttribute('active', '');
|
||||||
|
currentModuleId = '';
|
||||||
|
if (tabCache) tabCache.hideAllPanes();
|
||||||
|
const dashboard = document.querySelector('rstack-user-dashboard');
|
||||||
|
if (dashboard) { dashboard.style.display = ''; if (dashboard.refresh) dashboard.refresh(); }
|
||||||
|
const app = document.getElementById('app');
|
||||||
|
if (app) app.classList.remove('canvas-layout');
|
||||||
|
history.pushState({ dashboard: true, spaceSlug: spaceSlug }, '', '/' + spaceSlug);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tabBar.setLayers(layers);
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem(TABS_KEY, JSON.stringify(layers));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_tabChannel) {
|
||||||
|
_tabChannel.onmessage = (e) => {
|
||||||
|
if (e.data?.type !== 'tabs-sync') return;
|
||||||
|
const remoteLayers = e.data.layers || [];
|
||||||
|
for (const mid of (e.data.closed || [])) _closedModuleIds.add(mid);
|
||||||
|
reconcileRemoteLayers(remoteLayers);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('beforeunload', () => {
|
||||||
|
if (_tabChannel) _tabChannel.close();
|
||||||
|
});
|
||||||
|
|
||||||
// Fetch tabs from server for authenticated users (merge with localStorage)
|
// Fetch tabs from server for authenticated users (merge with localStorage)
|
||||||
(function syncTabsFromServer() {
|
(function syncTabsFromServer() {
|
||||||
|
|
@ -954,14 +1024,72 @@ export function renderShell(opts: ShellOptions): string {
|
||||||
// Never touch the active tab: it's managed locally by TabCache
|
// Never touch the active tab: it's managed locally by TabCache
|
||||||
// and the tab-bar component via layer-switch events.
|
// and the tab-bar component via layer-switch events.
|
||||||
sync.addEventListener('change', () => {
|
sync.addEventListener('change', () => {
|
||||||
layers = deduplicateLayers(sync.getLayers());
|
reconcileRemoteLayers(sync.getLayers());
|
||||||
tabBar.setLayers(layers);
|
|
||||||
tabBar.setFlows(sync.getFlows());
|
tabBar.setFlows(sync.getFlows());
|
||||||
const viewMode = sync.doc.layerViewMode;
|
const viewMode = sync.doc.layerViewMode;
|
||||||
if (viewMode) tabBar.setAttribute('view-mode', viewMode);
|
if (viewMode) tabBar.setAttribute('view-mode', viewMode);
|
||||||
saveTabs(); // keep localStorage in sync
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Keyboard shortcuts: Ctrl+1–9 (PWA) / Alt+1–9 (browser) ──
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
const digit = e.key >= '1' && e.key <= '9' ? e.key : null;
|
||||||
|
if (!digit) return;
|
||||||
|
const isStandalone = window.matchMedia('(display-mode: standalone)').matches;
|
||||||
|
if ((isStandalone && e.ctrlKey && !e.shiftKey && !e.altKey) ||
|
||||||
|
(!isStandalone && e.altKey && !e.ctrlKey && !e.shiftKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
const shortcuts = JSON.parse(localStorage.getItem('rspace-shortcuts') || '{}');
|
||||||
|
const targetModule = shortcuts[digit];
|
||||||
|
if (targetModule) {
|
||||||
|
const sw = document.querySelector('rstack-app-switcher');
|
||||||
|
if (sw) {
|
||||||
|
sw.dispatchEvent(new CustomEvent('module-select', {
|
||||||
|
detail: { moduleId: targetModule },
|
||||||
|
bubbles: true, composed: true,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch(ex) {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Swipe gestures on header: left/right to cycle rApps ──
|
||||||
|
(function() {
|
||||||
|
let touchStartX = 0, touchStartTime = 0;
|
||||||
|
const header = document.querySelector('.rstack-header');
|
||||||
|
if (!header) return;
|
||||||
|
header.addEventListener('touchstart', (e) => {
|
||||||
|
touchStartX = e.touches[0].clientX;
|
||||||
|
touchStartTime = Date.now();
|
||||||
|
}, { passive: true });
|
||||||
|
header.addEventListener('touchend', (e) => {
|
||||||
|
const dx = e.changedTouches[0].clientX - touchStartX;
|
||||||
|
const dt = Date.now() - touchStartTime;
|
||||||
|
if (Math.abs(dx) > 80 && dt < 300) {
|
||||||
|
try {
|
||||||
|
const shortcuts = JSON.parse(localStorage.getItem('rspace-shortcuts') || '{}');
|
||||||
|
const order = Object.entries(shortcuts)
|
||||||
|
.sort(([a],[b]) => a.localeCompare(b))
|
||||||
|
.map(([,m]) => m);
|
||||||
|
if (order.length < 2) return;
|
||||||
|
const current = document.body.dataset.moduleId || currentModuleId;
|
||||||
|
const idx = order.indexOf(current);
|
||||||
|
const next = dx < 0
|
||||||
|
? order[(idx + 1) % order.length]
|
||||||
|
: order[(idx - 1 + order.length) % order.length];
|
||||||
|
const sw = document.querySelector('rstack-app-switcher');
|
||||||
|
if (sw) {
|
||||||
|
sw.dispatchEvent(new CustomEvent('module-select', {
|
||||||
|
detail: { moduleId: next },
|
||||||
|
bubbles: true, composed: true,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch(ex) {}
|
||||||
|
}
|
||||||
|
}, { passive: true });
|
||||||
|
})();
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
${scripts}
|
${scripts}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
|
|
||||||
import { rspaceNavUrl, getCurrentModule, getCurrentSpace } from "../url-helpers";
|
import { rspaceNavUrl, getCurrentModule, getCurrentSpace } from "../url-helpers";
|
||||||
import { resetDocBridge, isEncryptedBackupEnabled, setEncryptedBackupEnabled } from "../local-first/encryptid-bridge";
|
import { resetDocBridge, isEncryptedBackupEnabled, setEncryptedBackupEnabled } from "../local-first/encryptid-bridge";
|
||||||
|
import { getShortcuts, setShortcut, removeShortcut } from "../shortcut-config";
|
||||||
|
|
||||||
const SESSION_KEY = "encryptid_session";
|
const SESSION_KEY = "encryptid_session";
|
||||||
const ENCRYPTID_URL = "https://auth.rspace.online";
|
const ENCRYPTID_URL = "https://auth.rspace.online";
|
||||||
|
|
@ -777,6 +778,8 @@ export class RStackIdentity extends HTMLElement {
|
||||||
}</div>
|
}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
${renderShortcutsSection()}
|
||||||
|
|
||||||
<div class="error" id="acct-error"></div>
|
<div class="error" id="acct-error"></div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
@ -940,6 +943,44 @@ export class RStackIdentity extends HTMLElement {
|
||||||
</div>`;
|
</div>`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderShortcutsSection = () => {
|
||||||
|
const isOpen = openSection === "shortcuts";
|
||||||
|
let body = "";
|
||||||
|
if (isOpen) {
|
||||||
|
const shortcuts = getShortcuts();
|
||||||
|
const modules: Array<{ id: string; name: string; icon: string; hidden?: boolean }> = (window as any).__rspaceAllModules || (window as any).__rspaceModuleList || [];
|
||||||
|
const slotRows = Array.from({ length: 9 }, (_, i) => {
|
||||||
|
const slot = String(i + 1);
|
||||||
|
const current = shortcuts[slot] || "";
|
||||||
|
const options = modules
|
||||||
|
.filter(m => !m.hidden)
|
||||||
|
.map(m => `<option value="${m.id}"${m.id === current ? " selected" : ""}>${m.icon} ${m.name}</option>`)
|
||||||
|
.join("");
|
||||||
|
return `
|
||||||
|
<div class="shortcut-slot">
|
||||||
|
<span class="slot-num">${slot}</span>
|
||||||
|
<select class="slot-select" data-slot="${slot}">
|
||||||
|
<option value="">None</option>
|
||||||
|
${options}
|
||||||
|
</select>
|
||||||
|
</div>`;
|
||||||
|
}).join("");
|
||||||
|
body = `
|
||||||
|
<div class="account-section-body">
|
||||||
|
<div class="shortcut-grid">${slotRows}</div>
|
||||||
|
<p class="shortcut-hint">Ctrl+1–9 (PWA) · Alt+1–9 (browser) · Swipe header on mobile</p>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
return `
|
||||||
|
<div class="account-section${isOpen ? " open" : ""}">
|
||||||
|
<div class="account-section-header" data-section="shortcuts">
|
||||||
|
<span>⌨️ rApp Shortcuts</span>
|
||||||
|
<span class="section-arrow">${isOpen ? "▾" : "▸"}</span>
|
||||||
|
</div>
|
||||||
|
${body}
|
||||||
|
</div>`;
|
||||||
|
};
|
||||||
|
|
||||||
const loadGuardians = async () => {
|
const loadGuardians = async () => {
|
||||||
if (guardiansLoaded || guardiansLoading) return;
|
if (guardiansLoaded || guardiansLoading) return;
|
||||||
guardiansLoading = true;
|
guardiansLoading = true;
|
||||||
|
|
@ -1241,6 +1282,18 @@ export class RStackIdentity extends HTMLElement {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Shortcut slot selects
|
||||||
|
overlay.querySelectorAll<HTMLSelectElement>(".slot-select").forEach(sel => {
|
||||||
|
sel.addEventListener("change", () => {
|
||||||
|
const slot = sel.dataset.slot!;
|
||||||
|
if (sel.value) {
|
||||||
|
setShortcut(slot, sel.value);
|
||||||
|
} else {
|
||||||
|
removeShortcut(slot);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
document.body.appendChild(overlay);
|
document.body.appendChild(overlay);
|
||||||
|
|
@ -1833,6 +1886,25 @@ const ACCOUNT_MODAL_STYLES = `
|
||||||
}
|
}
|
||||||
.toggle-switch input:checked + .toggle-slider { background: #059669; }
|
.toggle-switch input:checked + .toggle-slider { background: #059669; }
|
||||||
.toggle-switch input:checked + .toggle-slider::before { transform: translateX(16px); }
|
.toggle-switch input:checked + .toggle-slider::before { transform: translateX(16px); }
|
||||||
|
.shortcut-grid {
|
||||||
|
display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px;
|
||||||
|
}
|
||||||
|
.shortcut-slot {
|
||||||
|
display: flex; align-items: center; gap: 6px;
|
||||||
|
}
|
||||||
|
.slot-num {
|
||||||
|
width: 20px; height: 20px; border-radius: 4px; display: flex; align-items: center;
|
||||||
|
justify-content: center; font-size: 0.75rem; font-weight: 600; flex-shrink: 0;
|
||||||
|
background: var(--rs-btn-secondary-bg); color: var(--rs-text-secondary);
|
||||||
|
}
|
||||||
|
.slot-select {
|
||||||
|
flex: 1; min-width: 0; padding: 4px 6px; border-radius: 6px; font-size: 0.75rem;
|
||||||
|
background: var(--rs-btn-secondary-bg); border: 1px solid var(--rs-border);
|
||||||
|
color: var(--rs-text-primary); cursor: pointer;
|
||||||
|
}
|
||||||
|
.shortcut-hint {
|
||||||
|
margin: 8px 0 0; font-size: 0.7rem; color: var(--rs-text-muted); text-align: center;
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const SPACES_STYLES = `
|
const SPACES_STYLES = `
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
/**
|
||||||
|
* rApp Shortcut configuration utility.
|
||||||
|
*
|
||||||
|
* Reads/writes shortcut slot assignments (1–9) from localStorage.
|
||||||
|
* Used by the settings UI in rstack-identity.ts.
|
||||||
|
* The shell inline script reads localStorage directly (can't import ES modules).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const STORAGE_KEY = "rspace-shortcuts";
|
||||||
|
|
||||||
|
export function getShortcuts(): Record<string, string> {
|
||||||
|
try {
|
||||||
|
return JSON.parse(localStorage.getItem(STORAGE_KEY) || "{}");
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setShortcut(slot: string, moduleId: string): void {
|
||||||
|
if (slot < "1" || slot > "9") return;
|
||||||
|
const shortcuts = getShortcuts();
|
||||||
|
shortcuts[slot] = moduleId;
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(shortcuts));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeShortcut(slot: string): void {
|
||||||
|
const shortcuts = getShortcuts();
|
||||||
|
delete shortcuts[slot];
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(shortcuts));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getModuleForSlot(slot: string): string | null {
|
||||||
|
return getShortcuts()[slot] ?? null;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue