feat(shell): add rApp keyboard shortcuts and swipe gestures

Configurable shortcuts (1-9 slots) stored in localStorage. Ctrl+1-9
in PWA mode, Alt+1-9 in browser. Swipe left/right on header to cycle.
Settings UI added to account modal with 3x3 slot grid.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-20 13:03:35 -07:00
parent 8741f5dbd5
commit 75fba6da89
3 changed files with 238 additions and 4 deletions

View File

@ -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+19 (PWA) / Alt+19 (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}

View File

@ -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+19 (PWA) · Alt+19 (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 = `

34
shared/shortcut-config.ts Normal file
View File

@ -0,0 +1,34 @@
/**
* rApp Shortcut configuration utility.
*
* Reads/writes shortcut slot assignments (19) 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;
}