Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions playwright/specs/sheet_presence.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { test, expect, type Page } from '@playwright/test';

// 0-based cell locator. Row header is th (child 1), so data column c is
// td:nth-child(c + 2); data row r is tbody tr:nth-child(r + 1).
const cell = (page: Page, r: number, c: number) =>
page.locator(`.sheet-grid tbody tr:nth-child(${r + 1}) td:nth-child(${c + 2})`);

async function openSheet(page: Page, padId: string): Promise<void> {
await page.goto(`/s/${padId}`);
await page.locator('.sheet-grid').waitFor({ state: 'visible', timeout: 20000 });
}

async function commitCell(page: Page, r: number, c: number, text: string): Promise<void> {
await cell(page, r, c).click();
await page.keyboard.type(text, { delay: 30 });
await page.keyboard.press('Enter');
}

test.describe('Sheet live presence & live calculation', () => {
test('cursor presence and live calculation across two sessions', async ({ browser }) => {
test.setTimeout(120000);
const padId = `sheet-presence-${Date.now()}`;

// Session A sets up A1=10 and C2==B2+1.
const ctxA = await browser.newContext();
const pageA = await ctxA.newPage();
await openSheet(pageA, padId);
await commitCell(pageA, 0, 0, '10'); // A1
await commitCell(pageA, 1, 2, '=B2+1'); // C2

// Session B joins and sees the committed value.
const ctxB = await browser.newContext();
const pageB = await ctxB.newPage();
await openSheet(pageB, padId);
await expect(cell(pageB, 0, 0)).toHaveText('10', { timeout: 20000 });

// A focuses B2 -> B sees a remote cursor tag on B2.
await cell(pageA, 1, 1).click();
await expect(cell(pageB, 1, 1).locator('.sheet-remote-tag')).toBeVisible({ timeout: 20000 });

// A types a formula in B2 WITHOUT committing -> B sees the live formula text
// in B2 and C2 recomputed to 31, before Enter.
await pageA.keyboard.type('=A1*3', { delay: 50 });
await expect(cell(pageB, 1, 1)).toHaveText('=A1*3', { timeout: 20000 });
await expect(cell(pageB, 1, 2)).toHaveText('31', { timeout: 20000 });

// A commits -> B2 shows the computed result 30 (overlay replaced).
await pageA.keyboard.press('Enter');
await expect(cell(pageB, 1, 1)).toHaveText('30', { timeout: 20000 });

// A disconnects -> A's cursor tag disappears on B (reused USER_LEAVE).
await ctxA.close();
await expect(cell(pageB, 1, 1).locator('.sheet-remote-tag')).toHaveCount(0, { timeout: 20000 });

await ctxB.close();
});
});
3 changes: 3 additions & 0 deletions ui/build.js
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,9 @@ const results = await Promise.allSettled([
esbuild.build({ ...esbuildOpts, entryPoints: ["./src/pad.js"], outdir: '../assets/js/pad/assets', alias, loader: loaders, logLevel: 'info' }),
esbuild.build({ ...esbuildOpts, entryPoints: ["./src/welcome.js"], outdir: '../assets/js/welcome/assets', alias, loader: loaders, logLevel: 'info' }),
esbuild.build({ ...esbuildOpts, entryPoints: ["./src/timeslider.js"], outdir: '../assets/js/timeslider/assets', alias, loader: loaders, logLevel: 'info' }),
// entryPoints object form so the output is sheet.js (matches /js/sheet/assets/sheet.js
// served by lib/api/pad/sheetFrontend.go), not sheet.entry.js.
esbuild.build({ ...esbuildOpts, entryPoints: { sheet: "./src/sheet.entry.ts" }, outdir: '../assets/js/sheet/assets', alias, loader: loaders, logLevel: 'info' }),
// CSS bundles
esbuild.build({ ...esbuildOpts, entryPoints: ['../assets/css/skin/colibris/pad.css'], outdir: '../assets/css/build/skin/colibris', external: ['*.woff', '*.woff2', '*.ttf', '*.eot', '*.svg', '*.png', '*.jpg', '*.gif'], loader: loaders, logLevel: 'info' }),
esbuild.build({ ...esbuildOpts, entryPoints: ['../assets/css/static/pad.css'], outdir: '../assets/css/build/static', external: ['*.woff', '*.woff2', '*.ttf', '*.eot', '*.svg', '*.png', '*.jpg', '*.gif', '/font/*', 'font/*'], loader: loaders, logLevel: 'info' }),
Expand Down
1 change: 1 addition & 0 deletions ui/src/js/sheet/sheetEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ export function startSheetEditor(root: HTMLElement): void {
cols: 20,
rawValue,
displayValue,
readOnly: data.readonly,
onEdit: (r, c, raw) => {
if (!collab) return;
collab.applyLocal({ type: 'setCell', sheet: activeSheetId, baseRev: collab.rev, row: r, col: c, raw });
Expand Down
10 changes: 10 additions & 0 deletions ui/src/js/sheet/sheetPresence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,4 +60,14 @@ describe('effectiveCells overlay drives live recompute', () => {
expect(engine.getValue(1, 2).value).toBe('31'); // C2 = B2+1
expect(cells.find((c) => c.row === 1 && c.col === 1)?.raw).toBe('=A1*3');
});

it('live edit wins over a committed cell at the same coordinate', () => {
// The load-bearing overlay property: a remote in-progress raw must REPLACE
// the committed raw at the same (row,col), not merely add another entry.
const base = [{ row: 1, col: 1, raw: '=A1*99' }];
const live = [{ userId: 'a', name: 'A', color: '#f00', sheet: 's1', row: 1, col: 1, raw: '=A1*3' }];
const cells = effectiveCells(base, live);
expect(cells.filter((c) => c.row === 1 && c.col === 1)).toHaveLength(1);
expect(cells.find((c) => c.row === 1 && c.col === 1)?.raw).toBe('=A1*3');
});
});
10 changes: 8 additions & 2 deletions ui/src/js/sheet/sheetView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export interface SheetViewOptions {
onSelect?: (row: number, col: number) => void;
onLiveEdit?: (row: number, col: number, raw: string) => void;
onEditEnd?: (row: number, col: number, committed: boolean) => void;
readOnly?: boolean;
}

function colName(c: number): string {
Expand All @@ -44,6 +45,7 @@ const CSS = `
.sheet-grid td { outline: none; position: relative; }
.sheet-grid td:focus { box-shadow: inset 0 0 0 2px #64d29b; }
.sheet-remote-tag { position: absolute; top: -15px; left: -1px; font: 10px/14px system-ui, sans-serif; padding: 0 4px; color: #fff; border-radius: 3px 3px 3px 0; white-space: nowrap; z-index: 5; pointer-events: none; }
.sheet-remote-tag::after { content: attr(data-label); }
`;

export class DomSheetView {
Expand Down Expand Up @@ -83,7 +85,9 @@ export class DomSheetView {
const rowCells: HTMLTableCellElement[] = [];
for (let c = 0; c < opts.cols; c++) {
const td = document.createElement('td');
td.contentEditable = 'true';
// Read-only viewers get non-editable cells: with no typing, no live-edit
// or commit frames originate from the client (the server strips them too).
td.contentEditable = opts.readOnly ? 'false' : 'true';
this.attach(td, r, c);
tr.appendChild(td);
rowCells.push(td);
Expand Down Expand Up @@ -174,7 +178,9 @@ export class DomSheetView {
td.style.boxShadow = `inset 0 0 0 2px ${deco.color}`;
const tag = document.createElement('span');
tag.className = 'sheet-remote-tag';
tag.textContent = deco.name || 'anon';
// Label via a CSS ::after pseudo-element (data-label), NOT a text node,
// so the peer's name never leaks into the cell's textContent.
tag.setAttribute('data-label', deco.name || 'anon');
tag.style.background = deco.color;
td.appendChild(tag);
this.decorated.add(td);
Expand Down
8 changes: 5 additions & 3 deletions ui/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,10 @@ export default defineConfig(({ mode }) => {
'@': path.resolve(__dirname, '/src'),
},
},
plugins: [
commonjs(),
],
// rolldown-vite (v8) handles CommonJS natively. The @rollup/plugin-commonjs
// plugin deadlocks the rolldown transform while bundling HyperFormula, so it
// is skipped for the sheet entry (rolldown's native CJS handling covers it).
// Other entries still load it unchanged.
plugins: mode === 'sheet' ? [] : [commonjs()],
};
});
Loading