From 75fba6da89f35a97575c21189c9101e0b8d2d726 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Fri, 20 Mar 2026 13:03:35 -0700 Subject: [PATCH] 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 --- server/shell.ts | 136 ++++++++++++++++++++++++++- shared/components/rstack-identity.ts | 72 ++++++++++++++ shared/shortcut-config.ts | 34 +++++++ 3 files changed, 238 insertions(+), 4 deletions(-) create mode 100644 shared/shortcut-config.ts diff --git a/server/shell.ts b/server/shell.ts index 2edae10..5bee351 100644 --- a/server/shell.ts +++ b/server/shell.ts @@ -176,7 +176,7 @@ export function renderShell(opts: ShellOptions): string { - +
@@ -568,8 +568,78 @@ export function renderShell(opts: ShellOptions): string { } catch(e) {} }; 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) (function syncTabsFromServer() { try { @@ -954,14 +1024,72 @@ export function renderShell(opts: ShellOptions): string { // Never touch the active tab: it's managed locally by TabCache // and the tab-bar component via layer-switch events. sync.addEventListener('change', () => { - layers = deduplicateLayers(sync.getLayers()); - tabBar.setLayers(layers); + reconcileRemoteLayers(sync.getLayers()); tabBar.setFlows(sync.getFlows()); const viewMode = sync.doc.layerViewMode; 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 }); + })(); } ${scripts} diff --git a/shared/components/rstack-identity.ts b/shared/components/rstack-identity.ts index fac0083..7acb479 100644 --- a/shared/components/rstack-identity.ts +++ b/shared/components/rstack-identity.ts @@ -8,6 +8,7 @@ import { rspaceNavUrl, getCurrentModule, getCurrentSpace } from "../url-helpers"; import { resetDocBridge, isEncryptedBackupEnabled, setEncryptedBackupEnabled } from "../local-first/encryptid-bridge"; +import { getShortcuts, setShortcut, removeShortcut } from "../shortcut-config"; const SESSION_KEY = "encryptid_session"; const ENCRYPTID_URL = "https://auth.rspace.online"; @@ -777,6 +778,8 @@ export class RStackIdentity extends HTMLElement { }
+ ${renderShortcutsSection()} +
`; @@ -940,6 +943,44 @@ export class RStackIdentity extends HTMLElement { `; }; + 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 => ``) + .join(""); + return ` +
+ ${slot} + +
`; + }).join(""); + body = ` + `; + } + return ` + `; + }; + const loadGuardians = async () => { if (guardiansLoaded || guardiansLoading) return; guardiansLoading = true; @@ -1241,6 +1282,18 @@ export class RStackIdentity extends HTMLElement { }); } + // Shortcut slot selects + overlay.querySelectorAll(".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); @@ -1833,6 +1886,25 @@ const ACCOUNT_MODAL_STYLES = ` } .toggle-switch input:checked + .toggle-slider { background: #059669; } .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 = ` diff --git a/shared/shortcut-config.ts b/shared/shortcut-config.ts new file mode 100644 index 0000000..6e9febd --- /dev/null +++ b/shared/shortcut-config.ts @@ -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 { + 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; +}