UNPKG

ethercalc

Version:

Multi-User Spreadsheet Server — TypeScript rewrite (Cloudflare fullstack)

280 lines (264 loc) 10.5 kB
/** * Direct TypeScript port of `src/player-broadcast.ls`. * * Behavior preservation is load-bearing here — the SocialCalc runtime we * bundle on-page expects every hook below to be installed exactly once and * to forward to the captured "Orig" versions, or the toolbar + cell grid * stop updating (AGENTS.md §7 item 10). * * Call `installCallbacks(SocialCalc)` once, at startup, after * `SocialCalc.js` is on the page. Calling it twice is a no-op — we guard * with `SocialCalc.OrigDoPositionCalculations` exactly like the legacy file * did (line 4). */ import type { BroadcastFn, SocialCalcGlobal, SheetObject, ECellInfo, CellElement, } from './types.ts'; // ─── Helpers ───────────────────────────────────────────────────────────── /** * Parse `window.location.search` into a flat map. Direct port of the * `parseQuery` local at `player-broadcast.ls:7`. * * Exported separately so `main.ts` can share it (legacy code re-parsed from * `SocialCalc.requestParams`, but we want a single implementation). */ export function parseQuery(raw: string): Record<string, string> { let qstr = raw; if (qstr.charAt(0) === '?') qstr = qstr.substr(1); const query: Record<string, string> = {}; if (!qstr) return query; const params = qstr.split('&'); for (const pair of params) { const eq = pair.indexOf('='); const k = eq >= 0 ? pair.slice(0, eq) : pair; const v = eq >= 0 ? pair.slice(eq + 1) : ''; if (!k) continue; query[decodeURIComponent(k)] = decodeURIComponent(v); } return query; } export interface InstallOptions { /** The WS broadcast function from `ws-adapter.ts`. */ broadcast: BroadcastFn; /** `window.location.search`, injected so Node tests can supply a fixture. */ search?: string; /** Handle to the `window`-like host — tests can pass a stub. */ win?: { CryptoJS?: { MD5: (s: string) => { toString: () => string } }; __MULTI__?: { rows?: Array<{ link: string; title: string }> }; }; } // ─── Install (idempotent) ──────────────────────────────────────────────── /** * Install every player-broadcast hook onto the provided `SocialCalc` global. * Idempotent — if we've already run, all the `Orig*` sentinel fields are * populated and we bail out immediately. * * Returns `false` if the install was skipped (already installed), `true` * otherwise. Caller uses this only in tests; production code ignores it. */ export function installCallbacks( SocialCalc: SocialCalcGlobal, opts: InstallOptions, ): boolean { if (SocialCalc.OrigDoPositionCalculations) return false; const search = opts.search ?? ''; SocialCalc.requestParams = parseQuery(search); // 1. DoPositionCalculations — wake the server into sending presence. const origPos = SocialCalc.DoPositionCalculations; if (origPos) SocialCalc.OrigDoPositionCalculations = origPos; SocialCalc.DoPositionCalculations = function (this: unknown, ...args: unknown[]): unknown { const ret = origPos?.apply(SocialCalc, args); SocialCalc.Callbacks.broadcast?.('ask.ecell'); return ret; }; // 2. Snapshot-aware settings load — gated on CryptoJS being on the page. const cryptoHost = opts.win ?? {}; if (cryptoHost.CryptoJS) { const md5 = (s: string): string => cryptoHost.CryptoJS!.MD5(s).toString(); SocialCalc.hadSnapshot = true; const origLoad = SocialCalc.LoadEditorSettings; if (origLoad) SocialCalc.OrigLoadEditorSettings = origLoad; SocialCalc.LoadEditorSettings = (editor, str, flags) => { editor.SettingsCallbacks.ethercalc = { save: (): string => `ethercalc:${md5(editor.context.sheetobj.CreateSheetSave())}\n`, load: (ed, _setting, line): void => { const hash = line.replace(/^\w+:/, ''); if (hash === md5(ed.context.sheetobj.CreateSheetSave())) { SocialCalc.hadSnapshot = false; } else { SocialCalc.hadSnapshot = true; } }, }; // Restore the original so we only rewrite callbacks once per room. /* istanbul ignore else */ if (origLoad) SocialCalc.LoadEditorSettings = origLoad; else delete SocialCalc.LoadEditorSettings; origLoad?.(editor, str, flags); }; } else { SocialCalc.hadSnapshot = false; } // 3. Guard SizeSSDiv against null parent nodes (post-hibernation edge). const origSize = SocialCalc.SizeSSDiv; if (origSize) SocialCalc.OrigSizeSSDiv = origSize; SocialCalc.SizeSSDiv = (spreadsheet) => { if (!spreadsheet || !spreadsheet.parentNode) return; origSize?.(spreadsheet); }; // 4. Rewrite ScheduleSheetCommands — broadcast every user action. // SocialCalc.Sheet::ScheduleSheetCommands in the legacy source sets up a // bound method on every sheet instance. We do the same here. if (SocialCalc.Sheet && SocialCalc.Sheet.prototype) { SocialCalc.Sheet.prototype.ScheduleSheetCommands = function ( this: SheetObject, ...rest: [string, boolean, boolean] ): void { SocialCalc.ScheduleSheetCommands?.(this, rest[0], rest[1], rest[2]); }; } const origSched = SocialCalc.ScheduleSheetCommands; if (origSched) SocialCalc.OrigScheduleSheetCommands = origSched; SocialCalc.ScheduleSheetCommands = (sheet, cmdstr, saveundo, isRemote) => { let cmd = cmdstr.replace(/\n\n+/g, '\n'); if (!/\S/.test(cmd)) return; if (!isRemote && cmd !== 'redisplay' && cmd !== 'recalc') { // Multi-sheet rewrite of `$Title.A1` → `"index.1"!A1`. Matches // `player-broadcast.ls:58`. const multi = (opts.win as { __MULTI__?: { rows?: Array<{ link: string; title: string }> } } | undefined) ?.__MULTI__; if (multi?.rows?.length && /set \w+ formula /.test(cmd)) { for (const { link, title } of multi.rows) { const re = new RegExp(`\\$${title}\\.([A-Z]+[1-9][0-9]*)`, 'ig'); cmd = cmd.replace(re, `"${link.replace('/', '')}"!$1`); } } SocialCalc.Callbacks.broadcast?.('execute', { cmdstr: cmd, saveundo, room: sheet._room, }); } origSched?.(sheet, cmd, saveundo, isRemote); }; // 5. MoveECell override — broadcast cursor moves + track peer highlights. SocialCalc.MoveECell = (editor, targetCell) => { const highlights = editor.context.highlights; let newcell = targetCell; if (editor.ecell) { if (editor.ecell.coord === newcell) return newcell; SocialCalc.Callbacks.broadcast?.('ecell', { original: editor.ecell.coord, ecell: newcell, }); const origCell = SocialCalc.GetEditorCellElement?.( editor, editor.ecell.row, editor.ecell.col, ); delete highlights[editor.ecell.coord]; const range2 = editor.range2; if ( range2.hasrange && editor.ecell.row >= range2.top && editor.ecell.row <= range2.bottom && editor.ecell.col >= range2.left && editor.ecell.col <= range2.right ) { highlights[editor.ecell.coord] = 'range2'; } editor.UpdateCellCSS(origCell, editor.ecell.row, editor.ecell.col); editor.SetECellHeaders(''); editor.cellhandles.ShowCellHandles(false); } else { SocialCalc.Callbacks.broadcast?.('ecell', { ecell: newcell }); } newcell = editor.context.cellskip[newcell] ?? newcell; const cr = SocialCalc.coordToCr!(newcell) as ECellInfo; editor.ecell = cr; editor.ecell.coord = newcell; const cell: CellElement | undefined = SocialCalc.GetEditorCellElement?.( editor, editor.ecell.row, editor.ecell.col, ); highlights[newcell] = 'cursor'; for (const f of Object.keys(editor.MoveECellCallback)) { editor.MoveECellCallback[f]!(editor); } editor.UpdateCellCSS(cell, editor.ecell.row, editor.ecell.col); editor.SetECellHeaders('selected'); for (const f of Object.keys(editor.StatusCallback)) { const sc = editor.StatusCallback[f]!; sc.func(editor, 'moveecell', newcell, sc.params); } if (editor.busy) { editor.ensureecell = true; } else { editor.ensureecell = false; editor.EnsureECellVisible(); } return newcell; }; return true; } /** * Install the `broadcast` entry-point onto `SocialCalc.Callbacks`. * * Two-arg signature matches the legacy `SocialCalc.Callbacks.broadcast` that * the whole stack dereferences — see `AGENTS.md` §7 item 10. */ export function installBroadcast( SocialCalc: SocialCalcGlobal, broadcast: BroadcastFn, ): void { SocialCalc.Callbacks = SocialCalc.Callbacks ?? {}; SocialCalc.Callbacks.broadcast = broadcast; } /** * Wire `SocialCalc.RecalcInfo.LoadSheet(ref)` → `ask.recalc` broadcast. * Matches the clause in `player.ls:84`. */ export function installRecalcLoader( SocialCalc: SocialCalcGlobal, broadcast: BroadcastFn, ): void { SocialCalc.RecalcInfo.LoadSheetCache = {}; SocialCalc.RecalcInfo.LoadSheet = (ref: string): void => { // Legacy guard — the LS source checked `ref is /[^.=_a-zA-Z0-9]/`; in // JS `is` always yields false against a regex object, so the clause was // a dead-code no-op. We preserve the _actual_ legacy behavior by never // rejecting, but we still lowercase. const normalized = ref.toLowerCase(); broadcast('ask.recalc', { room: normalized }); }; } /** For tests: wipe every Orig* we installed so the next install() will run. */ export function uninstallCallbacks(SocialCalc: SocialCalcGlobal): void { if (SocialCalc.OrigDoPositionCalculations) { SocialCalc.DoPositionCalculations = SocialCalc.OrigDoPositionCalculations; } if (SocialCalc.OrigLoadEditorSettings) { SocialCalc.LoadEditorSettings = SocialCalc.OrigLoadEditorSettings; } if (SocialCalc.OrigSizeSSDiv) { SocialCalc.SizeSSDiv = SocialCalc.OrigSizeSSDiv; } if (SocialCalc.OrigScheduleSheetCommands) { SocialCalc.ScheduleSheetCommands = SocialCalc.OrigScheduleSheetCommands; } delete SocialCalc.OrigDoPositionCalculations; delete SocialCalc.OrigLoadEditorSettings; delete SocialCalc.OrigSizeSSDiv; delete SocialCalc.OrigScheduleSheetCommands; delete SocialCalc.requestParams; if (SocialCalc.Sheet?.prototype) { delete SocialCalc.Sheet.prototype.ScheduleSheetCommands; } }