@casualoffice/sheets 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/dist/api-BI2VLYQ6.d.cts +62 -0
  2. package/dist/api-BI2VLYQ6.d.ts +62 -0
  3. package/dist/chrome.cjs +2569 -0
  4. package/dist/chrome.cjs.map +1 -0
  5. package/dist/chrome.d.cts +96 -0
  6. package/dist/chrome.d.ts +96 -0
  7. package/dist/chrome.js +2556 -0
  8. package/dist/chrome.js.map +1 -0
  9. package/dist/collab.cjs +770 -0
  10. package/dist/collab.cjs.map +1 -0
  11. package/dist/collab.d.cts +248 -0
  12. package/dist/collab.d.ts +248 -0
  13. package/dist/collab.js +737 -0
  14. package/dist/collab.js.map +1 -0
  15. package/dist/embed/embed-runtime.js +128 -128
  16. package/dist/embed.cjs +166 -0
  17. package/dist/embed.cjs.map +1 -1
  18. package/dist/embed.d.cts +78 -3
  19. package/dist/embed.d.ts +78 -3
  20. package/dist/embed.js +166 -0
  21. package/dist/embed.js.map +1 -1
  22. package/dist/index.cjs +262 -165
  23. package/dist/index.cjs.map +1 -1
  24. package/dist/index.d.cts +4 -3
  25. package/dist/index.d.ts +4 -3
  26. package/dist/index.js +271 -168
  27. package/dist/index.js.map +1 -1
  28. package/dist/{protocol--KyBQUjU.d.cts → protocol-Cq4Cdoyi.d.cts} +19 -2
  29. package/dist/{protocol-cEzy7S0i.d.ts → protocol-DqDaG2yG.d.ts} +19 -2
  30. package/dist/sheets.cjs +102 -16
  31. package/dist/sheets.cjs.map +1 -1
  32. package/dist/sheets.d.cts +49 -63
  33. package/dist/sheets.d.ts +49 -63
  34. package/dist/sheets.js +111 -19
  35. package/dist/sheets.js.map +1 -1
  36. package/package.json +28 -3
  37. package/src/chrome/AutoSumPicker.tsx +176 -0
  38. package/src/chrome/BordersPicker.tsx +171 -0
  39. package/src/chrome/ChromeBottom.tsx +18 -0
  40. package/src/chrome/ChromeTop.tsx +21 -0
  41. package/src/chrome/ColorPicker.tsx +220 -0
  42. package/src/chrome/FindReplace.tsx +370 -0
  43. package/src/chrome/FormulaBar.tsx +378 -0
  44. package/src/chrome/Icon.tsx +43 -0
  45. package/src/chrome/MenuBar.tsx +336 -0
  46. package/src/chrome/NameBox.tsx +347 -0
  47. package/src/chrome/SheetTabs.tsx +346 -0
  48. package/src/chrome/StatusBar.tsx +232 -0
  49. package/src/chrome/Toolbar.tsx +401 -0
  50. package/src/chrome/fonts.ts +42 -0
  51. package/src/chrome/index.ts +24 -0
  52. package/src/collab/attachCollab.ts +151 -0
  53. package/src/collab/bridge-helpers.ts +97 -0
  54. package/src/collab/bridge.ts +885 -0
  55. package/src/collab/bridge.unit.test.ts +160 -0
  56. package/src/collab/index.ts +38 -0
  57. package/src/collab/replay-retry.ts +137 -0
  58. package/src/collab/replay-retry.unit.test.ts +223 -0
  59. package/src/collab/ws-url.ts +20 -0
  60. package/src/collab/ws-url.unit.test.ts +35 -0
  61. package/src/embed/EmbedHostTransport.ts +16 -1
  62. package/src/embed/EmbedTransport.ts +16 -0
  63. package/src/embed/EmbedTransport.unit.test.ts +88 -2
  64. package/src/embed/index.ts +7 -0
  65. package/src/embed/protocol.ts +34 -0
  66. package/src/embed-runtime/index.tsx +20 -0
  67. package/src/sheets/CasualSheets.tsx +204 -33
@@ -0,0 +1,401 @@
1
+ /**
2
+ * Toolbar — the rich built-in formatting toolbar for `<CasualSheets chrome>`.
3
+ *
4
+ * Drives the editor purely through `CasualSheetsAPI.executeCommand`, with
5
+ * Material Symbols icons + design-system tokens. No app context, no title/logo
6
+ * row (the host frames the editor with its own bar). Commands grouped with
7
+ * dividers, Office-style.
8
+ *
9
+ * Covered: font family/size · undo/redo · bold/italic/underline/strikethrough ·
10
+ * horizontal align · merge/unmerge · number formats (currency/percent/decimals).
11
+ * Reflects the active cell — toggles light up, dropdowns show the current font —
12
+ * via a command-execution subscription. Follow-up: text/fill colour pickers
13
+ * (need a swatch popover).
14
+ */
15
+
16
+ import { useEffect, useState, type CSSProperties } from 'react';
17
+ import { ICommandService } from '@univerjs/core';
18
+ import type { CasualSheetsAPI } from '../sheets/api';
19
+ import { Icon } from './Icon';
20
+ import { ensureChromeFonts } from './fonts';
21
+ import { ColorPicker } from './ColorPicker';
22
+ import { BordersPicker } from './BordersPicker';
23
+ import { AutoSumPicker } from './AutoSumPicker';
24
+
25
+ interface ActiveStyle {
26
+ bold: boolean;
27
+ italic: boolean;
28
+ underline: boolean;
29
+ strike: boolean;
30
+ ht: number; // HorizontalAlign of the active cell (0 = unset)
31
+ vt: number; // VerticalAlign of the active cell (0 = unset)
32
+ tb: number; // WrapStrategy of the active cell (0 = unset, 3 = wrap)
33
+ ff: string;
34
+ fs: number;
35
+ }
36
+
37
+ const NO_STYLE: ActiveStyle = {
38
+ bold: false,
39
+ italic: false,
40
+ underline: false,
41
+ strike: false,
42
+ ht: 0,
43
+ vt: 0,
44
+ tb: 0,
45
+ ff: '',
46
+ fs: 0,
47
+ };
48
+
49
+ function readActiveStyle(api: CasualSheetsAPI): ActiveStyle {
50
+ const sel = api.getSelection();
51
+ const sheet = api.univer.getActiveWorkbook()?.getActiveSheet();
52
+ if (!sel || !sheet) return NO_STYLE;
53
+ const range = sheet.getRange(sel.range.startRow, sel.range.startColumn);
54
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
55
+ const s = (range.getCellStyleData?.() ?? null) as any;
56
+ return {
57
+ bold: s?.bl === 1,
58
+ italic: s?.it === 1,
59
+ underline: s?.ul?.s === 1,
60
+ strike: s?.st?.s === 1,
61
+ ht: typeof s?.ht === 'number' ? s.ht : 0,
62
+ vt: typeof s?.vt === 'number' ? s.vt : 0,
63
+ tb: typeof s?.tb === 'number' ? s.tb : 0,
64
+ ff: typeof s?.ff === 'string' ? s.ff : '',
65
+ fs: typeof s?.fs === 'number' ? s.fs : 0,
66
+ };
67
+ }
68
+
69
+ const FONT_FAMILIES = ['Arial', 'Calibri', 'Times New Roman', 'Courier New', 'Georgia', 'Verdana'];
70
+ const FONT_SIZES = [8, 9, 10, 11, 12, 14, 16, 18, 20, 24, 28, 36, 48, 72];
71
+
72
+ interface ToolbarAction {
73
+ id: string;
74
+ label: string;
75
+ command: string;
76
+ icon: string;
77
+ params?: object;
78
+ }
79
+
80
+ // HorizontalAlign enum (@univerjs/core): LEFT=1, CENTER=2, RIGHT=3.
81
+ // VerticalAlign enum (@univerjs/core): TOP=1, MIDDLE=2, BOTTOM=3.
82
+ // WrapStrategy enum (@univerjs/core): OVERFLOW=1, CLIP=2, WRAP=3.
83
+ const GROUPS: ToolbarAction[][] = [
84
+ [
85
+ { id: 'undo', label: 'Undo', command: 'univer.command.undo', icon: 'undo' },
86
+ { id: 'redo', label: 'Redo', command: 'univer.command.redo', icon: 'redo' },
87
+ ],
88
+ [
89
+ { id: 'bold', label: 'Bold', command: 'sheet.command.set-range-bold', icon: 'format_bold' },
90
+ {
91
+ id: 'italic',
92
+ label: 'Italic',
93
+ command: 'sheet.command.set-range-italic',
94
+ icon: 'format_italic',
95
+ },
96
+ {
97
+ id: 'underline',
98
+ label: 'Underline',
99
+ command: 'sheet.command.set-range-underline',
100
+ icon: 'format_underlined',
101
+ },
102
+ {
103
+ id: 'strikethrough',
104
+ label: 'Strikethrough',
105
+ command: 'sheet.command.set-range-stroke',
106
+ icon: 'format_strikethrough',
107
+ },
108
+ ],
109
+ [
110
+ {
111
+ id: 'align-left',
112
+ label: 'Align left',
113
+ command: 'sheet.command.set-horizontal-text-align',
114
+ icon: 'format_align_left',
115
+ params: { value: 1 },
116
+ },
117
+ {
118
+ id: 'align-center',
119
+ label: 'Align center',
120
+ command: 'sheet.command.set-horizontal-text-align',
121
+ icon: 'format_align_center',
122
+ params: { value: 2 },
123
+ },
124
+ {
125
+ id: 'align-right',
126
+ label: 'Align right',
127
+ command: 'sheet.command.set-horizontal-text-align',
128
+ icon: 'format_align_right',
129
+ params: { value: 3 },
130
+ },
131
+ ],
132
+ [
133
+ {
134
+ id: 'align-top',
135
+ label: 'Align top',
136
+ command: 'sheet.command.set-vertical-text-align',
137
+ icon: 'vertical_align_top',
138
+ params: { value: 1 },
139
+ },
140
+ {
141
+ id: 'align-middle',
142
+ label: 'Align middle',
143
+ command: 'sheet.command.set-vertical-text-align',
144
+ icon: 'vertical_align_center',
145
+ params: { value: 2 },
146
+ },
147
+ {
148
+ id: 'align-bottom',
149
+ label: 'Align bottom',
150
+ command: 'sheet.command.set-vertical-text-align',
151
+ icon: 'vertical_align_bottom',
152
+ params: { value: 3 },
153
+ },
154
+ {
155
+ id: 'wrap-text',
156
+ label: 'Wrap text',
157
+ command: 'sheet.command.set-text-wrap',
158
+ icon: 'wrap_text',
159
+ params: { value: 3 },
160
+ },
161
+ ],
162
+ [
163
+ {
164
+ id: 'merge',
165
+ label: 'Merge cells',
166
+ command: 'sheet.command.add-worksheet-merge-all',
167
+ icon: 'cell_merge',
168
+ },
169
+ {
170
+ id: 'unmerge',
171
+ label: 'Unmerge cells',
172
+ command: 'sheet.command.remove-worksheet-merge',
173
+ icon: 'splitscreen_vertical_add',
174
+ },
175
+ ],
176
+ [
177
+ {
178
+ id: 'currency',
179
+ label: 'Currency format',
180
+ command: 'sheet.command.numfmt.set.currency',
181
+ icon: 'attach_money',
182
+ },
183
+ {
184
+ id: 'percent',
185
+ label: 'Percent format',
186
+ command: 'sheet.command.numfmt.set.percent',
187
+ icon: 'percent',
188
+ },
189
+ {
190
+ id: 'decimal-increase',
191
+ label: 'Increase decimals',
192
+ command: 'sheet.command.numfmt.add.decimal.command',
193
+ icon: 'decimal_increase',
194
+ },
195
+ {
196
+ id: 'decimal-decrease',
197
+ label: 'Decrease decimals',
198
+ command: 'sheet.command.numfmt.subtract.decimal.command',
199
+ icon: 'decimal_decrease',
200
+ },
201
+ ],
202
+ [
203
+ {
204
+ id: 'clear-format',
205
+ label: 'Clear formatting',
206
+ command: 'sheet.command.clear-selection-format',
207
+ icon: 'format_clear',
208
+ },
209
+ ],
210
+ ];
211
+
212
+ const BAR_STYLE: CSSProperties = {
213
+ display: 'flex',
214
+ alignItems: 'center',
215
+ gap: 2,
216
+ padding: '4px 8px',
217
+ borderBottom: '1px solid var(--cs-chrome-border, #e6e9ee)',
218
+ background: 'var(--cs-chrome-bg, #eef1f5)',
219
+ flex: '0 0 auto',
220
+ userSelect: 'none',
221
+ };
222
+
223
+ const BTN_STYLE: CSSProperties = {
224
+ width: 30,
225
+ height: 30,
226
+ display: 'inline-flex',
227
+ alignItems: 'center',
228
+ justifyContent: 'center',
229
+ border: 'none',
230
+ borderRadius: 6,
231
+ background: 'transparent',
232
+ cursor: 'pointer',
233
+ color: 'var(--cs-chrome-fg, #201f1e)',
234
+ padding: 0,
235
+ };
236
+
237
+ const DIVIDER_STYLE: CSSProperties = {
238
+ width: 1,
239
+ height: 18,
240
+ margin: '0 4px',
241
+ background: 'var(--cs-chrome-border, #cdd3db)',
242
+ flex: '0 0 auto',
243
+ };
244
+
245
+ const SELECT_STYLE: CSSProperties = {
246
+ height: 26,
247
+ border: '1px solid var(--cs-chrome-border, #cdd3db)',
248
+ borderRadius: 6,
249
+ background: 'var(--cs-chrome-input-bg, #fff)',
250
+ color: 'var(--cs-chrome-fg, #201f1e)',
251
+ font: 'inherit',
252
+ fontSize: 13,
253
+ padding: '0 4px',
254
+ cursor: 'pointer',
255
+ };
256
+
257
+ export interface ToolbarProps {
258
+ /** Live API, or `null` until the editor is ready. */
259
+ api: CasualSheetsAPI | null;
260
+ }
261
+
262
+ // Which actions reflect an active state, keyed off the active cell's style.
263
+ function isActive(id: string, s: ActiveStyle): boolean {
264
+ switch (id) {
265
+ case 'bold':
266
+ return s.bold;
267
+ case 'italic':
268
+ return s.italic;
269
+ case 'underline':
270
+ return s.underline;
271
+ case 'strikethrough':
272
+ return s.strike;
273
+ case 'align-left':
274
+ return s.ht === 1;
275
+ case 'align-center':
276
+ return s.ht === 2;
277
+ case 'align-right':
278
+ return s.ht === 3;
279
+ case 'align-top':
280
+ return s.vt === 1;
281
+ case 'align-middle':
282
+ return s.vt === 2;
283
+ case 'align-bottom':
284
+ return s.vt === 3;
285
+ case 'wrap-text':
286
+ return s.tb === 3;
287
+ default:
288
+ return false;
289
+ }
290
+ }
291
+
292
+ export function Toolbar({ api }: ToolbarProps) {
293
+ const [active, setActive] = useState<ActiveStyle>(NO_STYLE);
294
+
295
+ useEffect(() => {
296
+ ensureChromeFonts();
297
+ }, []);
298
+
299
+ // Reflect the active cell: subscribe to command activity (covers selection
300
+ // moves + style mutations) and re-read the style.
301
+ useEffect(() => {
302
+ if (!api) return;
303
+ const refresh = () => setActive(readActiveStyle(api));
304
+ refresh();
305
+ const injector = (api.univer as unknown as { _injector?: { get(t: unknown): unknown } })
306
+ ._injector;
307
+ const cmd = injector?.get(ICommandService) as
308
+ | { onCommandExecuted: (cb: () => void) => { dispose: () => void } }
309
+ | undefined;
310
+ const sub = cmd?.onCommandExecuted(() => refresh());
311
+ return () => sub?.dispose();
312
+ }, [api]);
313
+
314
+ const dispatch = (command: string, params?: object) => void api?.executeCommand(command, params);
315
+
316
+ // Reflect current font in the dropdowns; surface a non-listed value too.
317
+ const familyValue = active.ff || 'Arial';
318
+ const familyOptions =
319
+ active.ff && !FONT_FAMILIES.includes(active.ff) ? [active.ff, ...FONT_FAMILIES] : FONT_FAMILIES;
320
+ const sizeValue = active.fs || 11;
321
+ const sizeOptions =
322
+ active.fs && !FONT_SIZES.includes(active.fs) ? [active.fs, ...FONT_SIZES] : FONT_SIZES;
323
+
324
+ return (
325
+ <div style={BAR_STYLE} data-testid="casual-sheets-toolbar" role="toolbar" aria-label="Editor">
326
+ <select
327
+ aria-label="Font family"
328
+ data-testid="cs-font-family"
329
+ style={{ ...SELECT_STYLE, width: 116 }}
330
+ value={familyValue}
331
+ onChange={(e) => dispatch('sheet.command.set-range-font-family', { value: e.target.value })}
332
+ >
333
+ {familyOptions.map((f) => (
334
+ <option key={f} value={f}>
335
+ {f}
336
+ </option>
337
+ ))}
338
+ </select>
339
+ <select
340
+ aria-label="Font size"
341
+ data-testid="cs-font-size"
342
+ style={{ ...SELECT_STYLE, width: 56, marginLeft: 4 }}
343
+ value={sizeValue}
344
+ onChange={(e) =>
345
+ dispatch('sheet.command.set-range-fontsize', { value: Number(e.target.value) })
346
+ }
347
+ >
348
+ {sizeOptions.map((s) => (
349
+ <option key={s} value={s}>
350
+ {s}
351
+ </option>
352
+ ))}
353
+ </select>
354
+ <span style={DIVIDER_STYLE} aria-hidden />
355
+ {GROUPS.map((group, gi) => (
356
+ <span key={gi} style={{ display: 'inline-flex', alignItems: 'center' }}>
357
+ {gi > 0 && <span style={DIVIDER_STYLE} aria-hidden />}
358
+ {group.map((a) => {
359
+ const on = isActive(a.id, active);
360
+ const baseBg = on ? 'var(--cs-chrome-active, #e6f3f7)' : 'transparent';
361
+ return (
362
+ <button
363
+ key={a.id}
364
+ type="button"
365
+ title={a.label}
366
+ aria-label={a.label}
367
+ aria-pressed={on}
368
+ data-action={a.id}
369
+ data-active={on ? 'true' : undefined}
370
+ style={{
371
+ ...BTN_STYLE,
372
+ background: baseBg,
373
+ color: on ? 'var(--cs-chrome-active-fg, #0e7490)' : BTN_STYLE.color,
374
+ }}
375
+ // mousedown (not click) so the grid's selection isn't lost first.
376
+ onMouseDown={(e) => {
377
+ e.preventDefault();
378
+ dispatch(a.command, a.params);
379
+ }}
380
+ onMouseEnter={(e) => {
381
+ if (!on)
382
+ e.currentTarget.style.background = 'var(--cs-chrome-hover, rgba(0,0,0,0.06))';
383
+ }}
384
+ onMouseLeave={(e) => {
385
+ e.currentTarget.style.background = baseBg;
386
+ }}
387
+ >
388
+ <Icon name={a.icon} size={20} />
389
+ </button>
390
+ );
391
+ })}
392
+ </span>
393
+ ))}
394
+ <span style={DIVIDER_STYLE} aria-hidden />
395
+ <ColorPicker api={api} />
396
+ <BordersPicker api={api} />
397
+ <span style={DIVIDER_STYLE} aria-hidden />
398
+ <AutoSumPicker api={api} />
399
+ </div>
400
+ );
401
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Ensures the chrome's webfonts (Inter for UI, Material Symbols Outlined for
3
+ * icons) are loaded. The design system standardises on these; the rich
4
+ * `chrome="full"` toolbar/menus render Material Symbols ligatures.
5
+ *
6
+ * Idempotent + safe to call repeatedly: it no-ops if the links already exist
7
+ * (e.g. a host that already loads them, like our own app). Injected lazily only
8
+ * when chrome is actually shown, so bare-grid SDK consumers pull nothing.
9
+ *
10
+ * Hosts with a strict CSP (no fonts.googleapis.com) can pre-load the fonts
11
+ * themselves or self-host them; this is best-effort, not required for the editor
12
+ * to function (icons fall back to the ligature text).
13
+ */
14
+
15
+ const LINKS: Array<{ id: string; href: string }> = [
16
+ {
17
+ id: 'cs-font-inter',
18
+ href: 'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap',
19
+ },
20
+ {
21
+ id: 'cs-font-material-symbols',
22
+ href: 'https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,300..700,0..1,-50..200&display=block',
23
+ },
24
+ ];
25
+
26
+ let done = false;
27
+
28
+ export function ensureChromeFonts(): void {
29
+ if (done || typeof document === 'undefined') return;
30
+ done = true;
31
+ const head = document.head;
32
+ for (const { id, href } of LINKS) {
33
+ if (document.getElementById(id)) continue;
34
+ // Skip if the host already loaded the same family by any <link>.
35
+ if (document.querySelector(`link[href*="${href.split('?')[0]}"]`)) continue;
36
+ const link = document.createElement('link');
37
+ link.id = id;
38
+ link.rel = 'stylesheet';
39
+ link.href = href;
40
+ head.appendChild(link);
41
+ }
42
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Chrome — the Office shell as slot components for `<CasualSheets chrome>`.
3
+ *
4
+ * First slice (SDK_MIGRATION_PIPELINE Phase 1 step 2): a minimal built-in
5
+ * `Toolbar`. The rich shell (FormulaBar, MenuBar, TitleBar, StatusBar) lifts
6
+ * from `apps/web/src/shell/` here in later slices, behind `chrome="full"`.
7
+ */
8
+ export { Toolbar, type ToolbarProps } from './Toolbar';
9
+ export { FormulaBar, type FormulaBarProps } from './FormulaBar';
10
+ export { StatusBar, type StatusBarProps } from './StatusBar';
11
+ export { Icon, type IconProps } from './Icon';
12
+ export { ensureChromeFonts } from './fonts';
13
+ export { ColorPicker, type ColorPickerProps } from './ColorPicker';
14
+ export { BordersPicker, type BordersPickerProps } from './BordersPicker';
15
+ export { AutoSumPicker, type AutoSumPickerProps } from './AutoSumPicker';
16
+ export { MenuBar, type MenuBarProps } from './MenuBar';
17
+ export { NameBox, type NameBoxProps } from './NameBox';
18
+ export { SheetTabs, type SheetTabsProps } from './SheetTabs';
19
+ export { FindReplace, type FindReplaceProps } from './FindReplace';
20
+ // Lazy-loaded shells: chrome above (ChromeTop) and below (ChromeBottom) the grid,
21
+ // imported on demand by `<CasualSheets>` via the `@casualoffice/sheets/chrome`
22
+ // subpath so `chrome="none"` consumers don't bundle the chrome JS.
23
+ export { ChromeTop } from './ChromeTop';
24
+ export { ChromeBottom } from './ChromeBottom';
@@ -0,0 +1,151 @@
1
+ /**
2
+ * attachCollab — opt-in real-time co-editing for `<CasualSheets>`.
3
+ *
4
+ * The editor ships **collab-unaware**: it boots, edits, and persists with no
5
+ * knowledge of rooms or peers. A host that wants co-editing calls
6
+ * `attachCollab(api, { room, server })` once after `onReady` and gets back a
7
+ * detach handle. That's the entire public surface — everything else (presence
8
+ * UI, password prompts, room preflight, reconnect banners) is the host's to
9
+ * build on top of the returned `provider` / `doc`.
10
+ *
11
+ * Transport: Yjs + Hocuspocus, exactly as the reference host (`apps/web`) uses
12
+ * it. The non-negotiable Univer hooks live in `./bridge` — it subscribes to
13
+ * `ICommandService.onMutationExecutedForCollab`, applies remote mutations with
14
+ * `IExecutionOptions.fromCollab` (echo-loop prevention), and guards
15
+ * `params.__splitChunk__`.
16
+ *
17
+ * Persistence note: Yjs/Hocuspocus is the **realtime transport only**. The
18
+ * authoritative document is still saved by the host via WOPI / its own backend
19
+ * (the save/exit event contract) — collab does not turn the SDK into a store.
20
+ */
21
+
22
+ import * as Y from 'yjs';
23
+ import { HocuspocusProvider, HocuspocusProviderWebsocket } from '@hocuspocus/provider';
24
+ import type { IWorkbookData } from '@univerjs/core';
25
+ import type { FUniver } from '@univerjs/core/facade';
26
+ import type { CasualSheetsAPI } from '../sheets/api';
27
+ import { startBridge, type BridgeHandle } from './bridge';
28
+ import { buildWsUrl } from './ws-url';
29
+
30
+ /** Either the SDK's imperative API (`onReady`) or the bare FUniver facade.
31
+ * Collab only needs the facade, so a host that holds the raw FUniver (the
32
+ * reference app does) can attach without constructing a CasualSheetsAPI. */
33
+ export type CollabAttachable = CasualSheetsAPI | FUniver;
34
+
35
+ /** Pull the FUniver facade out of whichever attachable form was passed.
36
+ * FUniver exposes `getActiveWorkbook` directly; CasualSheetsAPI wraps the
37
+ * facade on `.univer`. Discriminating on the method (not on `'univer' in …`)
38
+ * is unambiguous either way. */
39
+ function resolveFacade(api: CollabAttachable): FUniver {
40
+ const maybe = api as Partial<FUniver> & Partial<CasualSheetsAPI>;
41
+ return typeof maybe.getActiveWorkbook === 'function'
42
+ ? (api as FUniver)
43
+ : (maybe.univer as FUniver);
44
+ }
45
+
46
+ /** `write` peers broadcast their edits; `view` peers only receive. The
47
+ * client-side gate is belt-and-braces — real enforcement is the server's
48
+ * `role` check on the WS upgrade. */
49
+ export type CollabRole = 'view' | 'write';
50
+
51
+ /** Coarse connection state mapped from Hocuspocus's provider status. */
52
+ export type CollabConnectionStatus = 'connecting' | 'live' | 'offline';
53
+
54
+ export interface AttachCollabOptions {
55
+ /** Room / document id. Becomes the Hocuspocus document name. */
56
+ room: string;
57
+ /** Base WebSocket URL of the collab server, e.g. `wss://host/yjs`. */
58
+ server: string;
59
+ /** Room password, if the server gates the room. Sent on the WS URL as
60
+ * `p=…` so the upgrade handler can validate before the protocol handshake. */
61
+ password?: string;
62
+ /** Auth token for the Hocuspocus handshake. The provider only sends its
63
+ * auth submessage when this is truthy; servers with an `onAuthenticate`
64
+ * hook keep the connection queued without it. Defaults to `'anon'` (the
65
+ * reference server's hook only reads the `role` query param). */
66
+ token?: string;
67
+ /** `view` joins read-only. Defaults to `'write'`. */
68
+ role?: CollabRole;
69
+ /**
70
+ * Called when a peer's compaction snapshot arrives — the host swaps the
71
+ * workbook (typically `api.loadSnapshot(wb)`). MAY return a promise; the
72
+ * bridge pauses op-log replay until it resolves so later mutations don't
73
+ * land on the pre-swap unit.
74
+ */
75
+ onSnapshot?: (wb: IWorkbookData) => void | Promise<void>;
76
+ /** Connection-status transitions — drive a status pill / offline banner. */
77
+ onStatus?: (status: CollabConnectionStatus) => void;
78
+ }
79
+
80
+ export interface CollabHandle {
81
+ /** The underlying Yjs document — introspection / devtools / extra maps. */
82
+ readonly doc: Y.Doc;
83
+ /** The Hocuspocus provider — `awareness` for presence, `on('status')`, etc. */
84
+ readonly provider: HocuspocusProvider;
85
+ /** The mutation bridge — replay-failure diagnostics live here. */
86
+ readonly bridge: BridgeHandle;
87
+ /** Last known connection status. */
88
+ status(): CollabConnectionStatus;
89
+ /** Detach: tear down bridge + provider + doc. Idempotent. */
90
+ detach(): void;
91
+ }
92
+
93
+ /**
94
+ * Attach real-time collab to a live editor. Call once after `onReady`.
95
+ * Returns a {@link CollabHandle}; call `.detach()` to leave the room
96
+ * (and always before the editor unmounts).
97
+ */
98
+ export function attachCollab(api: CollabAttachable, opts: AttachCollabOptions): CollabHandle {
99
+ const facade = resolveFacade(api);
100
+ const role: CollabRole = opts.role ?? 'write';
101
+
102
+ const doc = new Y.Doc();
103
+ // Match the reference host: drop the reconnect timeout to 10 s so a dropped
104
+ // socket surfaces as `offline` quickly rather than after the 30 s default.
105
+ const ws = new HocuspocusProviderWebsocket({
106
+ url: buildWsUrl(opts.server, opts.room, role, opts.password),
107
+ messageReconnectTimeout: 10_000,
108
+ });
109
+ const provider = new HocuspocusProvider({
110
+ websocketProvider: ws,
111
+ name: opts.room,
112
+ document: doc,
113
+ // Truthy token so the handshake completes even when the server has an
114
+ // onAuthenticate hook (used for role enforcement). See option docs above.
115
+ token: opts.token ?? 'anon',
116
+ });
117
+
118
+ const bridge = startBridge(facade, doc, {
119
+ role,
120
+ awareness: provider.awareness ?? undefined,
121
+ onSnapshotReceived: opts.onSnapshot,
122
+ });
123
+
124
+ let current: CollabConnectionStatus = 'connecting';
125
+ const onStatus = (ev: { status: string }) => {
126
+ const next: CollabConnectionStatus =
127
+ ev.status === 'connected' ? 'live' : ev.status === 'connecting' ? 'connecting' : 'offline';
128
+ if (next === current) return;
129
+ current = next;
130
+ opts.onStatus?.(next);
131
+ };
132
+ provider.on('status', onStatus);
133
+
134
+ let detached = false;
135
+ const detach = () => {
136
+ if (detached) return;
137
+ detached = true;
138
+ provider.off('status', onStatus);
139
+ bridge.dispose();
140
+ provider.destroy();
141
+ doc.destroy();
142
+ };
143
+
144
+ return {
145
+ doc,
146
+ provider,
147
+ bridge,
148
+ status: () => current,
149
+ detach,
150
+ };
151
+ }