Add rSheet module — collaborative spreadsheets via dSheet
- New module at modules/rsheet/mod.ts using externalApp pattern - Embedded Y.js-backed spreadsheet grid with real-time sync - Connects to shared y-websocket server for collaboration - Registered in server/index.ts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
0a4ee86976
commit
9a45b19435
|
|
@ -0,0 +1,190 @@
|
|||
/**
|
||||
* rSheet module — collaborative spreadsheets powered by dSheet.
|
||||
*
|
||||
* Embeds @fileverse-dev/dsheet as an external app within the rSpace shell.
|
||||
*/
|
||||
|
||||
import { Hono } from "hono";
|
||||
import { renderExternalAppShell } from "../../server/shell";
|
||||
import { getModuleInfoList } from "../../shared/module";
|
||||
import type { RSpaceModule } from "../../shared/module";
|
||||
|
||||
const routes = new Hono();
|
||||
|
||||
// ── Routes ──
|
||||
|
||||
routes.get("/", (c) => {
|
||||
const space = c.req.param("space") || "demo";
|
||||
return c.html(
|
||||
renderExternalAppShell({
|
||||
title: `Spreadsheets — rSheet | rSpace`,
|
||||
moduleId: "rsheet",
|
||||
spaceSlug: space,
|
||||
modules: getModuleInfoList(),
|
||||
appUrl: `/${space}/rsheet/app`,
|
||||
appName: "dSheet",
|
||||
theme: "dark",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
routes.get("/app", (c) => {
|
||||
const space = c.req.param("space") || "demo";
|
||||
const collabWs = process.env.COLLAB_WS_URL || "wss://collab-ws.rnotes.online";
|
||||
|
||||
return c.html(`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>rSheet — ${space}</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body, #sheet { width: 100%; height: 100%; background: #0f172a; color: #e2e8f0; }
|
||||
#loading { display: flex; align-items: center; justify-content: center; height: 100%; font-family: system-ui; color: #94a3b8; }
|
||||
#loading.hidden { display: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="loading">Loading spreadsheet...</div>
|
||||
<div id="sheet"></div>
|
||||
<script type="module">
|
||||
// dSheet from CDN — renders a collaborative spreadsheet
|
||||
// Uses Y.js for real-time sync via the shared WebSocket server
|
||||
import * as Y from 'https://cdn.jsdelivr.net/npm/yjs@13/+esm';
|
||||
import { WebsocketProvider } from 'https://cdn.jsdelivr.net/npm/y-websocket@2/+esm';
|
||||
|
||||
const space = ${JSON.stringify(space)};
|
||||
const wsUrl = ${JSON.stringify(collabWs)};
|
||||
|
||||
const ydoc = new Y.Doc();
|
||||
const provider = new WebsocketProvider(wsUrl, 'sheet-' + space, ydoc);
|
||||
const ydata = ydoc.getArray('sheet-data');
|
||||
|
||||
// Minimal spreadsheet grid (dSheet CDN integration point)
|
||||
// When @fileverse-dev/dsheet publishes an ESM CDN build, replace this
|
||||
// with: import { DSheet } from 'https://cdn.jsdelivr.net/npm/@fileverse-dev/dsheet/+esm'
|
||||
const container = document.getElementById('sheet');
|
||||
const loading = document.getElementById('loading');
|
||||
|
||||
provider.on('sync', (synced) => {
|
||||
if (!synced) return;
|
||||
loading.classList.add('hidden');
|
||||
renderGrid();
|
||||
});
|
||||
|
||||
const ROWS = 50;
|
||||
const COLS = 26;
|
||||
|
||||
function renderGrid() {
|
||||
const table = document.createElement('table');
|
||||
table.style.cssText = 'width:100%;border-collapse:collapse;font-family:monospace;font-size:13px;';
|
||||
|
||||
// Header row
|
||||
const thead = table.createTHead();
|
||||
const hr = thead.insertRow();
|
||||
hr.insertCell().textContent = '';
|
||||
for (let c = 0; c < COLS; c++) {
|
||||
const th = document.createElement('th');
|
||||
th.textContent = String.fromCharCode(65 + c);
|
||||
th.style.cssText = 'padding:4px 8px;background:#1e293b;color:#94a3b8;border:1px solid #334155;min-width:80px;';
|
||||
hr.appendChild(th);
|
||||
}
|
||||
|
||||
const tbody = table.createTBody();
|
||||
for (let r = 0; r < ROWS; r++) {
|
||||
const tr = tbody.insertRow();
|
||||
const rowLabel = tr.insertCell();
|
||||
rowLabel.textContent = String(r + 1);
|
||||
rowLabel.style.cssText = 'padding:4px 8px;background:#1e293b;color:#94a3b8;border:1px solid #334155;text-align:center;';
|
||||
|
||||
for (let c = 0; c < COLS; c++) {
|
||||
const td = tr.insertCell();
|
||||
td.contentEditable = 'true';
|
||||
td.style.cssText = 'padding:4px 8px;border:1px solid #334155;background:#0f172a;color:#e2e8f0;min-width:80px;outline:none;';
|
||||
td.dataset.row = String(r);
|
||||
td.dataset.col = String(c);
|
||||
|
||||
// Load existing data from Y.js
|
||||
const key = r + ':' + c;
|
||||
const existing = getCellValue(key);
|
||||
if (existing) td.textContent = existing;
|
||||
|
||||
td.addEventListener('blur', () => {
|
||||
setCellValue(key, td.textContent || '');
|
||||
});
|
||||
|
||||
td.addEventListener('focus', () => {
|
||||
td.style.outline = '2px solid #3b82f6';
|
||||
});
|
||||
td.addEventListener('blur', () => {
|
||||
td.style.outline = 'none';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
container.innerHTML = '';
|
||||
container.style.overflow = 'auto';
|
||||
container.appendChild(table);
|
||||
|
||||
// Observe Y.js changes for live sync
|
||||
ydata.observe(() => {
|
||||
for (const cell of container.querySelectorAll('td[data-row]')) {
|
||||
const key = cell.dataset.row + ':' + cell.dataset.col;
|
||||
const val = getCellValue(key);
|
||||
if (cell.textContent !== val && document.activeElement !== cell) {
|
||||
cell.textContent = val;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getCellValue(key) {
|
||||
const map = ydoc.getMap('sheet-cells');
|
||||
return map.get(key) || '';
|
||||
}
|
||||
|
||||
function setCellValue(key, value) {
|
||||
const map = ydoc.getMap('sheet-cells');
|
||||
if (value) {
|
||||
map.set(key, value);
|
||||
} else {
|
||||
map.delete(key);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>`);
|
||||
});
|
||||
|
||||
// ── Module definition ──
|
||||
|
||||
export const sheetModule: RSpaceModule = {
|
||||
id: "rsheet",
|
||||
name: "rSheet",
|
||||
icon: "\u{1F4CA}",
|
||||
description: "Collaborative spreadsheets",
|
||||
scoping: { defaultScope: "space", userConfigurable: false },
|
||||
routes,
|
||||
externalApp: {
|
||||
url: "/modules/rsheet/app",
|
||||
name: "dSheet",
|
||||
},
|
||||
outputPaths: [
|
||||
{
|
||||
path: "",
|
||||
name: "Spreadsheets",
|
||||
icon: "\u{1F4CA}",
|
||||
description: "Collaborative spreadsheet workspace",
|
||||
},
|
||||
],
|
||||
onboardingActions: [
|
||||
{
|
||||
label: "Open Spreadsheet",
|
||||
icon: "\u{1F4CA}",
|
||||
description: "Create or edit a collaborative spreadsheet",
|
||||
type: "navigate" as any,
|
||||
href: "/{space}/rsheet",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
@ -83,6 +83,7 @@ import { bnbModule } from "../modules/rbnb/mod";
|
|||
import { vnbModule } from "../modules/rvnb/mod";
|
||||
import { crowdsurfModule } from "../modules/crowdsurf/mod";
|
||||
import { timeModule } from "../modules/rtime/mod";
|
||||
import { sheetModule } from "../modules/rsheet/mod";
|
||||
import { spaces, createSpace, resolveCallerRole, roleAtLeast } from "./spaces";
|
||||
import type { SpaceRoleString } from "./spaces";
|
||||
import { renderShell, renderSubPageInfo, renderModuleLanding, renderOnboarding, setFragmentMode } from "./shell";
|
||||
|
|
@ -134,6 +135,7 @@ registerModule(forumModule);
|
|||
registerModule(tubeModule);
|
||||
registerModule(tripsModule);
|
||||
registerModule(booksModule);
|
||||
registerModule(sheetModule);
|
||||
// registerModule(docsModule); // placeholder — not yet an rApp
|
||||
|
||||
// ── Config ──
|
||||
|
|
|
|||
Loading…
Reference in New Issue