@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
@@ -7,8 +7,8 @@
7
7
  import { strict as assert } from 'node:assert';
8
8
  import { test } from 'node:test';
9
9
 
10
- import { EmbedTransport, isCasualEnvelope } from './';
11
- import type { CasualEnvelope } from './';
10
+ import { EmbedTransport, EmbedHostTransport, isCasualEnvelope } from './';
11
+ import type { CasualEnvelope, SaveNotifyData, ExitData } from './';
12
12
 
13
13
  function fakeHostWindow() {
14
14
  let handler: ((ev: unknown) => void) | null = null;
@@ -135,6 +135,92 @@ test('sendHello emits an editor.hello envelope', () => {
135
135
  assert.deepEqual((env.data as { capabilities: string[] }).capabilities, ['save', 'load']);
136
136
  });
137
137
 
138
+ test('sendSaveNotify emits a fire-and-forget save.notify with the snapshot', () => {
139
+ const host = fakeHostWindow();
140
+ const parent = fakeParent();
141
+ const transport = new EmbedTransport({
142
+ app: 'sheet',
143
+ hostOrigin: 'https://drive.example',
144
+ version: '1.0.0',
145
+ commit: 'abc',
146
+ capabilities: [],
147
+ parentWindow: parent,
148
+ hostWindow: host,
149
+ });
150
+ const snapshot = { id: 'wb1', sheets: {} };
151
+ transport.sendSaveNotify({ snapshot, reason: 'shortcut' });
152
+ assert.equal(parent.sent.length, 1);
153
+ const env = parent.sent[0].msg as CasualEnvelope;
154
+ assert.equal(env.type, 'casual.save.notify');
155
+ assert.equal(env.app, 'sheet');
156
+ // No id — it's a notification, not a request awaiting a response.
157
+ assert.equal(env.id, undefined);
158
+ assert.equal((env.data as { reason: string }).reason, 'shortcut');
159
+ assert.deepEqual((env.data as { snapshot: unknown }).snapshot, snapshot);
160
+ });
161
+
162
+ test('sendExit emits an exit envelope carrying the final snapshot', () => {
163
+ const host = fakeHostWindow();
164
+ const parent = fakeParent();
165
+ const transport = new EmbedTransport({
166
+ app: 'sheet',
167
+ hostOrigin: 'https://drive.example',
168
+ version: '1.0.0',
169
+ commit: 'abc',
170
+ capabilities: [],
171
+ parentWindow: parent,
172
+ hostWindow: host,
173
+ });
174
+ const snapshot = { id: 'wb1', sheets: { s1: {} } };
175
+ transport.sendExit({ snapshot });
176
+ assert.equal(parent.sent.length, 1);
177
+ const env = parent.sent[0].msg as CasualEnvelope;
178
+ assert.equal(env.type, 'casual.exit');
179
+ assert.equal(env.id, undefined);
180
+ assert.deepEqual((env.data as { snapshot: unknown }).snapshot, snapshot);
181
+ });
182
+
183
+ test('host transport dispatches save.notify + exit to its handlers', async () => {
184
+ const host = fakeHostWindow();
185
+ // The host transport gates on ev.source === iframeWindow; use a sentinel.
186
+ const iframeWindow = { postMessage() {} } as unknown as Window;
187
+ const hostTransport = new EmbedHostTransport({
188
+ app: 'sheet',
189
+ iframeWindow,
190
+ embedOrigin: 'https://embed.example',
191
+ hostWindow: host,
192
+ });
193
+ let saved: SaveNotifyData | null = null;
194
+ let exited: ExitData | null = null;
195
+ hostTransport.on({
196
+ onSaveNotify: (d) => {
197
+ saved = d;
198
+ },
199
+ onExit: (d) => {
200
+ exited = d;
201
+ },
202
+ });
203
+ host.fire({
204
+ origin: 'https://embed.example',
205
+ source: iframeWindow,
206
+ data: {
207
+ type: 'casual.save.notify',
208
+ app: 'sheet',
209
+ v: 1,
210
+ data: { snapshot: { id: 'wb1' }, reason: 'host' },
211
+ },
212
+ });
213
+ host.fire({
214
+ origin: 'https://embed.example',
215
+ source: iframeWindow,
216
+ data: { type: 'casual.exit', app: 'sheet', v: 1, data: { snapshot: { id: 'wb1' } } },
217
+ });
218
+ await new Promise((r) => setTimeout(r, 0));
219
+ assert.equal(saved!.reason, 'host');
220
+ assert.deepEqual(saved!.snapshot, { id: 'wb1' });
221
+ assert.deepEqual(exited!.snapshot, { id: 'wb1' });
222
+ });
223
+
138
224
  test('destroy detaches the listener', () => {
139
225
  const host = fakeHostWindow();
140
226
  const transport = new EmbedTransport({
@@ -12,6 +12,11 @@ export {
12
12
  type EmbedTransportHandlers,
13
13
  type EmbedTransportOptions,
14
14
  } from './EmbedTransport';
15
+ export {
16
+ EmbedHostTransport,
17
+ type EmbedHostHandlers,
18
+ type EmbedHostTransportOptions,
19
+ } from './EmbedHostTransport';
15
20
  export {
16
21
  isCasualEnvelope,
17
22
  type CasualApp,
@@ -26,6 +31,8 @@ export {
26
31
  type SaveResponseData,
27
32
  type SaveResponseDataOk,
28
33
  type SaveResponseDataErr,
34
+ type SaveNotifyData,
35
+ type ExitData,
29
36
  type SelectionChangedData,
30
37
  type TelemetryEventData,
31
38
  type LockLostData,
@@ -87,6 +87,40 @@ export interface SaveResponseDataErr {
87
87
 
88
88
  export type SaveResponseData = SaveResponseDataOk | SaveResponseDataErr;
89
89
 
90
+ // ---------------------------------------------------------------
91
+ // Save / exit notifications (editor → host, fire-and-forget)
92
+ // ---------------------------------------------------------------
93
+ //
94
+ // The lightweight counterpart to the bytes-carrying load/save *request*
95
+ // pair above. These mirror the SDK's React `onSave` / `onExit` hooks one
96
+ // for one — the "one shape, two surfaces" save/exit contract — so a host
97
+ // that frames the iframe gets the same persistence signals a host that
98
+ // renders `<CasualSheets>` directly does. Fire-and-forget: the editor
99
+ // never owns storage; the host decides what to do with the snapshot.
100
+ //
101
+ // `casual.save.request` (above) stays the WOPI-style path for hosts that
102
+ // want xlsx bytes + etag round-trips; these notifications are the simpler
103
+ // "here's the current state, persist it however you like" path.
104
+
105
+ /** Editor → host: the user explicitly asked to save — Ctrl/Cmd+S inside
106
+ * the iframe, or the host's `casual.command.save`. Carries the full
107
+ * editor snapshot as JSON (sheet app: Univer's `IWorkbookData`). Mirror
108
+ * of the React `onSave` hook. v0.9+. */
109
+ export interface SaveNotifyData {
110
+ /** App-specific snapshot JSON. Sheet: `IWorkbookData`. */
111
+ snapshot: unknown;
112
+ /** What triggered the save: the in-editor shortcut or a host command. */
113
+ reason: 'shortcut' | 'host';
114
+ }
115
+
116
+ /** Editor → host: the editor is unmounting / navigating away. Carries
117
+ * the final snapshot so the host can persist on exit. Mirror of the
118
+ * React `onExit` hook. v0.9+. */
119
+ export interface ExitData {
120
+ /** App-specific snapshot JSON. Sheet: `IWorkbookData`. */
121
+ snapshot: unknown;
122
+ }
123
+
90
124
  // ---------------------------------------------------------------
91
125
  // Selection + telemetry + lock (editor → host notifications)
92
126
  // ---------------------------------------------------------------
@@ -230,6 +230,14 @@ function EmbeddedSheets({
230
230
  // iframe ships the minimal editor. Hosts that need feature plugins use
231
231
  // the React `<CasualSheets>` component directly (lazyPlugins defaults on).
232
232
  lazyPlugins={false}
233
+ // Phase 2 save/exit contract — the iframe surface of the same
234
+ // `onSave` / `onExit` hooks the React component exposes. The editor
235
+ // never persists; it hands the snapshot to the host over postMessage
236
+ // and the host decides what to do with it (WOPI, Drive, localStorage
237
+ // demo, …). `onSave` fires on Ctrl/Cmd+S inside the iframe; `onExit`
238
+ // on unmount.
239
+ onSave={(snapshot) => transport.sendSaveNotify({ snapshot, reason: 'shortcut' })}
240
+ onExit={(snapshot) => transport.sendExit({ snapshot })}
233
241
  onReady={(api) => {
234
242
  // Wire host → editor command.execute (Drive's custom toolbar
235
243
  // calls this for bold / italic / undo / …). Maps the small
@@ -246,6 +254,7 @@ function EmbeddedSheets({
246
254
  // a different module. The runtime behaviour is fine.
247
255
  const apiAny = api as unknown as {
248
256
  executeCommand(id: string, params?: object): Promise<unknown>;
257
+ getSnapshot(): unknown;
249
258
  univer: { getActiveWorkbook(): { getActiveSheet(): SheetLike | null } | null };
250
259
  };
251
260
  transport.on({
@@ -259,6 +268,17 @@ function EmbeddedSheets({
259
268
  /* swallow — bad command id from a stale host shouldn't crash the iframe */
260
269
  }
261
270
  },
271
+ // Host clicked its own "Save" button (host-rendered chrome above
272
+ // the iframe). Snapshot now and emit the same save notification
273
+ // the in-iframe Ctrl/Cmd+S path does — only the `reason` differs.
274
+ onCommandSave: () => {
275
+ try {
276
+ const snapshot = apiAny.getSnapshot();
277
+ if (snapshot) transport.sendSaveNotify({ snapshot, reason: 'host' });
278
+ } catch {
279
+ /* snapshot unavailable during boot — ignore */
280
+ }
281
+ },
262
282
  });
263
283
 
264
284
  // Editor → host: emit format state on a coarse interval. The
@@ -13,11 +13,11 @@
13
13
  * snapshot already uses, idle-loaded otherwise. Pass `lazyPlugins={false}`
14
14
  * for the minimal editor.
15
15
  *
16
+ * Formula compute runs on the main thread by default; pass `formula={{ worker }}`
17
+ * to move it off-thread (the SDK then wires `UniverRPCMainThreadPlugin` to the
18
+ * host's worker — see the `formula` prop).
19
+ *
16
20
  * Intentionally NOT included (host can layer on top via FUniver):
17
- * - Formula compute via Web Worker — `notExecuteFormula: false`
18
- * is the default; the formula engine runs on the main thread.
19
- * Host wires `UniverRPCMainThreadPlugin` + a worker URL itself
20
- * if it wants the off-main path.
21
21
  * - Snapshot swap (this component mounts a snapshot once; change
22
22
  * the React `key` to remount with a fresh snapshot).
23
23
  * - Paste-merge hooks, dev helpers, zoom-shortcut overrides,
@@ -28,7 +28,15 @@
28
28
  * styles from this entry if the host doesn't reach the styles export.
29
29
  */
30
30
 
31
- import { useEffect, useRef, type CSSProperties } from 'react';
31
+ import {
32
+ lazy,
33
+ Suspense,
34
+ useEffect,
35
+ useRef,
36
+ useState,
37
+ type CSSProperties,
38
+ type KeyboardEvent as ReactKeyboardEvent,
39
+ } from 'react';
32
40
  import {
33
41
  ICommandService,
34
42
  LocaleType,
@@ -51,13 +59,27 @@ import { UniverDocsPlugin } from '@univerjs/docs';
51
59
  import { UniverDocsUIPlugin } from '@univerjs/docs-ui';
52
60
  import { UniverSheetsPlugin } from '@univerjs/sheets';
53
61
  import { UniverSheetsUIPlugin } from '@univerjs/sheets-ui';
54
- import { UniverSheetsFormulaPlugin } from '@univerjs/sheets-formula';
62
+ import { UniverSheetsFormulaPlugin, CalculationMode } from '@univerjs/sheets-formula';
55
63
  import { UniverSheetsFormulaUIPlugin } from '@univerjs/sheets-formula-ui';
56
64
  import { UniverSheetsNumfmtPlugin } from '@univerjs/sheets-numfmt';
57
65
  import { UniverSheetsNumfmtUIPlugin } from '@univerjs/sheets-numfmt-ui';
66
+ // Type-only — erased at build, so `@univerjs/rpc` stays a runtime-optional peer
67
+ // (loaded via dynamic import only when a formula worker is passed).
68
+ import type { UniverRPCMainThreadPlugin as RpcMainThreadPluginType } from '@univerjs/rpc';
58
69
 
59
70
  import { createCasualSheetsAPI, type CasualSheetsAPI } from './api';
60
71
  import { eagerLoadForSnapshot, idleLoadAll, setUniverForLazyLoad } from '../univer/lazy-plugins';
72
+ // Chrome is lazy-loaded from the `@casualoffice/sheets/chrome` subpath (NOT a
73
+ // relative import — that would inline under this build's splitting:false). The
74
+ // subpath is externalised in tsup, so the consumer's bundler code-splits it and
75
+ // `chrome="none"` hosts (the default + the apps/web reference host) never load
76
+ // the chrome chunk.
77
+ const ChromeTop = lazy(() =>
78
+ import('@casualoffice/sheets/chrome').then((m) => ({ default: m.ChromeTop })),
79
+ );
80
+ const ChromeBottom = lazy(() =>
81
+ import('@casualoffice/sheets/chrome').then((m) => ({ default: m.ChromeBottom })),
82
+ );
61
83
 
62
84
  export interface CasualSheetsProps {
63
85
  /** Workbook snapshot to mount. Read once on initial mount; change
@@ -80,6 +102,13 @@ export interface CasualSheetsProps {
80
102
  onChange?: (snapshot: IWorkbookData) => void;
81
103
  /** Debounce window for `onChange`, in ms. Default 400. */
82
104
  onChangeDebounceMs?: number;
105
+ /** Explicit save — fired when the user presses Ctrl/Cmd+S inside the editor
106
+ * (the browser's save dialog is suppressed). The host persists the snapshot.
107
+ * Part of the "host owns storage" contract: the SDK never writes a store. */
108
+ onSave?: (snapshot: IWorkbookData) => void;
109
+ /** Fired once when the editor unmounts, with the final snapshot — the host's
110
+ * last chance to persist before the workbook is disposed. */
111
+ onExit?: (snapshot: IWorkbookData) => void;
83
112
  /** Lazy-load the feature plugins (conditional formatting, data
84
113
  * validation, hyperlinks, notes, tables, comments, drawings, sort,
85
114
  * filter, find/replace). Default `true`: plugins whose data is in
@@ -88,6 +117,29 @@ export interface CasualSheetsProps {
88
117
  * minimal editor (render + formula + numfmt only) — the embed-iframe
89
118
  * build does this to stay a single self-contained bundle. */
90
119
  lazyPlugins?: boolean;
120
+ /** Escape hatch fired after the SDK registers its built-in plugins but BEFORE
121
+ * the workbook unit is created — the host can `univer.registerPlugin(...)`
122
+ * additional plugins here (e.g. an off-main formula worker via
123
+ * `UniverRPCMainThreadPlugin`, crosshair-highlight, zen-editor). Anything
124
+ * registered after `createUnit` would miss the unit's plugin-init pass, so
125
+ * register-time extras must go through this hook. Power hosts (the reference
126
+ * app) use it to share the SDK editor core while keeping their extra plugins;
127
+ * most integrators never need it. NOT covered by semver — it hands you the
128
+ * raw `Univer` instance. */
129
+ onBeforeCreateUnit?: (univer: Univer) => void;
130
+ /** Off-main formula compute. By default the formula engine runs on the MAIN
131
+ * thread (fine for typical sheets, zero host setup). Provide a Web Worker (or
132
+ * its URL) to move compute off-thread so paste / sort / fill on large
133
+ * workbooks don't freeze the UI: the SDK then registers the formula plugins
134
+ * with `notExecuteFormula` and wires `UniverRPCMainThreadPlugin` to your
135
+ * worker. The host owns the worker (the SDK never bundles one — that's brittle
136
+ * across bundlers) and must have `@univerjs/rpc` installed. The worker script
137
+ * is the standard Univer formula worker (see the reference app's
138
+ * `apps/web/src/univer/formula-worker.ts`). */
139
+ formula?: {
140
+ /** A constructed `Worker`, or a URL/string the RPC plugin loads. */
141
+ worker?: Worker | string;
142
+ };
91
143
  /** Locale identifier. Defaults to `LocaleType.EN_US`. */
92
144
  locale?: LocaleType;
93
145
  /** Locale string bundle. Optional — Univer's default English
@@ -114,6 +166,18 @@ export interface CasualSheetsProps {
114
166
  * Univer's design — a host that embeds the editor inside a light page
115
167
  * should scope the editor or accept the global dark CSS. */
116
168
  appearance?: 'light' | 'dark';
169
+ /** Office chrome rendered around the grid:
170
+ * - `'none'` (default): bare grid — the host supplies its own chrome.
171
+ * - `'minimal'` / `'full'`: the built-in Office shell — a menu bar
172
+ * (Edit/Insert/Format/Data/View), a formatting toolbar (font family/size,
173
+ * bold/italic/underline/strike, text & fill colour, borders, h/v align,
174
+ * wrap, merge, number formats, clear format, AutoSum), a formula bar with a
175
+ * name box + function autocomplete, a worksheet tab strip (switch/add/
176
+ * rename/delete), and a status bar (Average/Count/Numerical Count/Min/Max/
177
+ * Sum + zoom). All driven through the facade, themed via `--cs-chrome-*`
178
+ * (light/dark). `'minimal'` and `'full'` currently render the same shell;
179
+ * `'full'` is where richer panels (find/replace, charts, …) will land. */
180
+ chrome?: 'none' | 'minimal' | 'full';
117
181
  /** Container style. Default fills the parent. */
118
182
  style?: CSSProperties;
119
183
  /** Container className for additional styling hooks. */
@@ -140,13 +204,18 @@ export function CasualSheets({
140
204
  onReady,
141
205
  onChange,
142
206
  onChangeDebounceMs = 400,
207
+ onSave,
208
+ onExit,
143
209
  lazyPlugins = true,
210
+ onBeforeCreateUnit,
211
+ formula,
144
212
  locale = LocaleType.EN_US,
145
213
  locales,
146
214
  logLevel = LogLevel.WARN,
147
215
  ui,
148
216
  theme = defaultTheme,
149
217
  appearance = 'light',
218
+ chrome = 'none',
150
219
  style,
151
220
  className,
152
221
  testId = 'casual-sheets',
@@ -158,9 +227,20 @@ export function CasualSheets({
158
227
  const onChangeRef = useRef(onChange);
159
228
  onChangeRef.current = onChange;
160
229
  const hasOnChange = useRef(!!onChange).current;
230
+ // Latest save/exit callbacks, called via refs so they fire without re-running
231
+ // the boot effect. onExit is read in cleanup; onSave on the Ctrl/Cmd+S handler.
232
+ const onSaveRef = useRef(onSave);
233
+ onSaveRef.current = onSave;
234
+ const onExitRef = useRef(onExit);
235
+ onExitRef.current = onExit;
161
236
  // The live FUniver facade, captured at mount so the reactive appearance
162
237
  // effect can reach Univer's ThemeService without re-running boot.
163
238
  const apiRef = useRef<CasualSheetsAPI | null>(null);
239
+ // The live API as state, so the built-in chrome (FormulaBar) re-renders and
240
+ // subscribes once the editor is ready. Only set when chrome is shown — the
241
+ // bare-grid path never triggers this re-render. A single post-mount setState
242
+ // doesn't disturb the grid (Univer owns its canvas outside React).
243
+ const [chromeApi, setChromeApi] = useState<CasualSheetsAPI | null>(null);
164
244
 
165
245
  useEffect(() => {
166
246
  const container = hostRef.current;
@@ -175,36 +255,56 @@ export function CasualSheets({
175
255
 
176
256
  const uiOpts = { ...DEFAULT_UI, ...ui, container };
177
257
 
178
- univer.registerPlugin(UniverRenderEnginePlugin);
179
- // Formula compute runs on the MAIN THREAD (no RPC worker). The library build
180
- // externalises @univerjs (see tsup.config.ts), so the host provides a single
181
- // redi/@univerjs copy — the duplicate-redi that previously made the formula
182
- // plugins fail (and which the SDK worked around by dropping them) is gone.
183
- // apps/web offloads compute to a worker for perf on huge sheets, but a worker
184
- // shipped inside a published package is brittle to bundle in arbitrary hosts;
185
- // main-thread keeps the SDK editor self-contained. SDK restructure Batch 2.
186
- univer.registerPlugin(UniverFormulaEnginePlugin);
187
- univer.registerPlugin(UniverUIPlugin, uiOpts);
188
- univer.registerPlugin(UniverDocsPlugin);
189
- univer.registerPlugin(UniverDocsUIPlugin);
190
- univer.registerPlugin(UniverSheetsPlugin);
191
- univer.registerPlugin(UniverSheetsUIPlugin);
192
- univer.registerPlugin(UniverSheetsFormulaPlugin);
193
- univer.registerPlugin(UniverSheetsFormulaUIPlugin);
194
- univer.registerPlugin(UniverSheetsNumfmtPlugin);
195
- univer.registerPlugin(UniverSheetsNumfmtUIPlugin);
196
-
197
- // Register the lazy-loader's holder so the eager/idle loaders can reach this
198
- // univer. CasualSheets uses its own (bundled) copy of the loader — the
199
- // exported `@casualoffice/sheets/univer` is @internal and only the host app's
200
- // legacy UniverSheet consumes it, so there's no cross-instance state to share.
201
- if (lazyPlugins) setUniverForLazyLoad(univer);
258
+ // `formula.worker` → off-main compute. Default = main thread (fine for
259
+ // typical sheets, zero host setup).
260
+ const offMain = !!formula?.worker;
202
261
 
203
262
  let cancelled = false;
204
263
  let changeTimer: ReturnType<typeof setTimeout> | null = null;
205
264
  let changeSub: { dispose: () => void } | undefined;
206
265
 
207
266
  void (async () => {
267
+ // Plugin registration runs here (not synchronously) so the OPTIONAL RPC
268
+ // transport can be `await import`ed FIRST and registered in its correct
269
+ // slot — right after the formula engine, before sheets. Registering it out
270
+ // of order (or after createUnit) leaves the formula engine's worker channel
271
+ // unwired → cells stay 0. Dynamic import keeps `@univerjs/rpc` a true
272
+ // optional peer (only loaded when a worker is passed).
273
+ let RPCMainThreadPlugin: typeof RpcMainThreadPluginType | null = null;
274
+ if (offMain && formula?.worker) {
275
+ RPCMainThreadPlugin = (await import('@univerjs/rpc')).UniverRPCMainThreadPlugin;
276
+ if (cancelled) return;
277
+ }
278
+
279
+ univer.registerPlugin(UniverRenderEnginePlugin);
280
+ univer.registerPlugin(
281
+ UniverFormulaEnginePlugin,
282
+ offMain ? { notExecuteFormula: true } : undefined,
283
+ );
284
+ if (RPCMainThreadPlugin && formula?.worker) {
285
+ univer.registerPlugin(RPCMainThreadPlugin, { workerURL: formula.worker });
286
+ }
287
+ univer.registerPlugin(UniverUIPlugin, uiOpts);
288
+ univer.registerPlugin(UniverDocsPlugin);
289
+ univer.registerPlugin(UniverDocsUIPlugin);
290
+ univer.registerPlugin(UniverSheetsPlugin, offMain ? { notExecuteFormula: true } : undefined);
291
+ univer.registerPlugin(UniverSheetsUIPlugin);
292
+ univer.registerPlugin(
293
+ UniverSheetsFormulaPlugin,
294
+ offMain
295
+ ? { notExecuteFormula: true, initialFormulaComputing: CalculationMode.NO_CALCULATION }
296
+ : undefined,
297
+ );
298
+ univer.registerPlugin(UniverSheetsFormulaUIPlugin);
299
+ univer.registerPlugin(UniverSheetsNumfmtPlugin);
300
+ univer.registerPlugin(UniverSheetsNumfmtUIPlugin);
301
+
302
+ // Lazy-loader holder (the loader is @internal so a relative import shares
303
+ // no cross-instance state) + host plugin escape hatch — both before
304
+ // createUnit.
305
+ if (lazyPlugins) setUniverForLazyLoad(univer);
306
+ onBeforeCreateUnit?.(univer);
307
+
208
308
  // Eager-load any feature plugin whose data already lives in initialData
209
309
  // (CF rules, tables, hyperlinks, …) BEFORE createUnit — Univer's resource
210
310
  // manager silently drops keys for plugins that aren't registered when it
@@ -218,6 +318,9 @@ export function CasualSheets({
218
318
 
219
319
  const api = createCasualSheetsAPI(FUniver.newAPI(univer));
220
320
  apiRef.current = api;
321
+ // Hand the live API to the built-in chrome (FormulaBar subscribes to it).
322
+ // Only when chrome is shown, so bare-grid consumers never re-render.
323
+ if (!cancelled && chrome !== 'none') setChromeApi(api);
221
324
  // Apply the initial appearance now that the editor exists (the reactive
222
325
  // effect below also runs on mount, but apiRef may not be set yet when it
223
326
  // first fires — this guarantees dark mode from the first paint).
@@ -258,7 +361,14 @@ export function CasualSheets({
258
361
  cancelled = true;
259
362
  if (changeTimer) clearTimeout(changeTimer);
260
363
  changeSub?.dispose();
364
+ // Last-chance persist: emit the final snapshot before the workbook is
365
+ // disposed (disposal is deferred via microtask below, so it's still alive).
366
+ if (onExitRef.current) {
367
+ const snap = apiRef.current?.getSnapshot();
368
+ if (snap) onExitRef.current(snap);
369
+ }
261
370
  apiRef.current = null;
371
+ setChromeApi(null);
262
372
  if (lazyPlugins) setUniverForLazyLoad(null);
263
373
  // Defer disposal off the React render phase — Univer owns its
264
374
  // own React root, and a synchronous unmount mid-render warns
@@ -281,13 +391,74 @@ export function CasualSheets({
281
391
  applyAppearance(api, container, appearance);
282
392
  }, [appearance]);
283
393
 
394
+ // Ctrl/Cmd+S anywhere in the editor → onSave (suppress the browser dialog).
395
+ // Capture phase so we beat Univer's own key handling on the canvas.
396
+ const onKeyDownCapture = (e: ReactKeyboardEvent<HTMLDivElement>) => {
397
+ if ((e.metaKey || e.ctrlKey) && (e.key === 's' || e.key === 'S')) {
398
+ e.preventDefault();
399
+ const snap = apiRef.current?.getSnapshot();
400
+ if (snap) onSaveRef.current?.(snap);
401
+ }
402
+ };
403
+
404
+ // chrome="none" (default) keeps the exact bare-grid shape existing consumers
405
+ // rely on (embed-runtime, hosts that bring their own shell). Any other level
406
+ // wraps the grid in a flex column with the built-in chrome above it; the grid
407
+ // container (hostRef, where Univer mounts) fills the remaining space.
408
+ if (chrome === 'none') {
409
+ return (
410
+ <div
411
+ ref={hostRef}
412
+ onKeyDownCapture={onKeyDownCapture}
413
+ style={{ ...DEFAULT_STYLE, ...style }}
414
+ className={className}
415
+ data-testid={testId}
416
+ />
417
+ );
418
+ }
419
+
420
+ // The built-in chrome components read their colours from `--cs-chrome-*` CSS
421
+ // vars (with light fallbacks). Set them on the wrapper so the toolbar / formula
422
+ // bar / status bar flip with `appearance` — reactive via the prop. (Hosts can
423
+ // still override any of these vars themselves.)
424
+ // Values from @schnsrw/design-system tokens (surface-strip / text / border)
425
+ // — adopted directly (not as a package dep) so the chrome matches the suite.
426
+ const dark = appearance === 'dark';
427
+ const chromeVars = {
428
+ '--cs-chrome-bg': dark ? '#2a2e35' : '#eef1f5',
429
+ '--cs-chrome-fg': dark ? '#e6e6e6' : '#201f1e',
430
+ '--cs-chrome-muted': dark ? '#b0b3ba' : '#605e5c',
431
+ '--cs-chrome-border': dark ? '#32363d' : '#e6e9ee',
432
+ '--cs-chrome-input-bg': dark ? '#23262c' : '#ffffff',
433
+ '--cs-chrome-hover': dark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)',
434
+ // accent (design-system #0e7490) for active toggle states.
435
+ '--cs-chrome-active': dark ? 'rgba(21,151,186,0.22)' : '#e6f3f7',
436
+ '--cs-chrome-active-fg': dark ? '#7fd3e6' : '#0e7490',
437
+ } as CSSProperties;
438
+
284
439
  return (
285
440
  <div
286
- ref={hostRef}
287
- style={{ ...DEFAULT_STYLE, ...style }}
288
441
  className={className}
289
442
  data-testid={testId}
290
- />
443
+ onKeyDownCapture={onKeyDownCapture}
444
+ style={{
445
+ ...DEFAULT_STYLE,
446
+ ...chromeVars,
447
+ ...style,
448
+ display: 'flex',
449
+ flexDirection: 'column',
450
+ }}
451
+ >
452
+ {/* Bars appear once their lazy chunk loads (a tick after first paint); the
453
+ grid host is OUTSIDE Suspense so Univer mounts immediately. */}
454
+ <Suspense fallback={null}>
455
+ <ChromeTop api={chromeApi} />
456
+ </Suspense>
457
+ <div ref={hostRef} style={{ flex: '1 1 auto', minHeight: 0, position: 'relative' }} />
458
+ <Suspense fallback={null}>
459
+ <ChromeBottom api={chromeApi} />
460
+ </Suspense>
461
+ </div>
291
462
  );
292
463
  }
293
464