@casualoffice/sheets 0.12.0 → 0.14.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.
@@ -1,52 +1,708 @@
1
1
  /**
2
2
  * MenuBar — the built-in dropdown menu row for `<CasualSheets chrome>`.
3
3
  *
4
- * Office-style horizontal menu strip (no logo, no title — the host frames the
5
- * editor with its own bar): Edit · Insert · Format · Data. Each button opens a
6
- * dropdown of items that dispatch a single verified Univer command through
7
- * `CasualSheetsAPI.executeCommand`, then close the menu.
4
+ * Office / Google-Sheets-style horizontal menu strip (no logo, no title — the
5
+ * host frames the editor with its own bar): File · Edit · View · Insert ·
6
+ * Format · Data · Help. Each top-level button opens a dropdown of items that
7
+ * either dispatch a verified Univer command / facade call through the
8
+ * `CasualSheetsAPI` (no app context), or — for actions that need a dialog the
9
+ * SDK doesn't ship — route through the optional `onDialogRequest` host hook.
10
+ *
11
+ * This mirrors `apps/web/src/shell/MenuBar.tsx`, but reimplements every action
12
+ * INLINE against `api` / `api.univer` (the FUniver facade) + `api.executeCommand`.
13
+ * The command ids + facade logic are copied verbatim from
14
+ * `apps/web/src/shell/home-tab-actions.ts` + `tab-actions.ts` so behaviour
15
+ * matches the real app exactly.
16
+ *
17
+ * Feature flags: pass `features` to hide a control or whole menu group when its
18
+ * feature is disabled. Defaults to all-enabled. A control whose feature is
19
+ * `false` does not render. An entire top-level menu that ends up with no
20
+ * runnable items is dropped.
21
+ *
22
+ * Dialog routing: items marked `dialog: '<kind>'` (Format Cells, Insert Chart,
23
+ * PivotTable, Find & Replace, Insert/Delete cells, Sparkline, Name Manager,
24
+ * Goal Seek, Data Validation, Conditional Formatting, …) call
25
+ * `onDialogRequest(kind, context)` so the host renders its own UI. When
26
+ * `onDialogRequest` is not provided, those items are omitted (the SDK never
27
+ * fakes a dialog).
8
28
  *
9
29
  * Self-contained: only one menu is open at a time; Escape and an outside
10
- * pointerdown close it. Every command id below was verified against the fork
11
- * (`vendor/univer-revamp/packages`); items whose command can't be verified are
12
- * omitted rather than dispatched blindly.
30
+ * pointerdown close it.
13
31
  */
14
32
 
15
- import { useEffect, useRef, useState, type CSSProperties } from 'react';
33
+ import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react';
34
+ import { BorderStyleTypes, BorderType } from '@univerjs/core';
35
+ import type { FUniver } from '@univerjs/core/facade';
16
36
  import type { CasualSheetsAPI } from '../sheets/api';
37
+ import { ensurePluginByName } from '../univer';
17
38
  import { Icon } from './Icon';
18
39
  import { ensureChromeFonts } from './fonts';
19
40
 
20
- type MenuId = 'edit' | 'insert' | 'format' | 'data' | 'view';
41
+ type MenuId = 'file' | 'edit' | 'view' | 'insert' | 'format' | 'data' | 'help';
21
42
 
22
- interface MenuItem {
23
- /** Stable id drives the per-item testid `cs-menuitem-<id>`. */
24
- id: string;
25
- label: string;
26
- command: string;
27
- icon?: string;
28
- params?: object;
29
- }
43
+ /**
44
+ * Dialog kinds the host can choose to render via `onDialogRequest`. These are
45
+ * the actions the SDK chrome can't fulfil on its own (no built-in modal). The
46
+ * string is passed straight to the host hook; the `context` (when present)
47
+ * carries the pre-resolved A1 selection so the host doesn't have to re-read it.
48
+ */
49
+ export type MenuDialogKind =
50
+ | 'format-cells'
51
+ | 'find-replace'
52
+ | 'insert-cells'
53
+ | 'delete-cells'
54
+ | 'paste-special'
55
+ | 'insert-chart'
56
+ | 'insert-pivot'
57
+ | 'insert-sparkline'
58
+ | 'insert-function'
59
+ | 'name-manager'
60
+ | 'goal-seek'
61
+ | 'data-validation'
62
+ | 'conditional-formatting'
63
+ | 'custom-sort'
64
+ | 'properties'
65
+ | 'about'
66
+ | 'keyboard-shortcuts';
30
67
 
31
- interface Menu {
68
+ type RunFn = (api: CasualSheetsAPI) => void;
69
+
70
+ type MenuItemDef =
71
+ | {
72
+ kind: 'item';
73
+ id: string;
74
+ label: string;
75
+ icon?: string;
76
+ shortcut?: string;
77
+ /** Dispatch a command / facade call directly. */
78
+ run?: RunFn;
79
+ /** Route through the host's `onDialogRequest`. Omitted if no host hook. */
80
+ dialog?: MenuDialogKind;
81
+ /** Feature gate — item hidden when `features[feature] === false`. */
82
+ feature?: string;
83
+ }
84
+ | { kind: 'separator'; id: string; feature?: string }
85
+ | {
86
+ kind: 'submenu';
87
+ id: string;
88
+ label: string;
89
+ icon?: string;
90
+ items: MenuItemDef[];
91
+ feature?: string;
92
+ };
93
+
94
+ interface MenuDef {
32
95
  id: MenuId;
33
96
  label: string;
34
- items: MenuItem[];
97
+ /** Feature gate for the whole menu. */
98
+ feature?: string;
99
+ items: MenuItemDef[];
100
+ }
101
+
102
+ /**
103
+ * Pretty-print a `Ctrl+Shift+X` shortcut for the current platform. The SDK
104
+ * doesn't import the app's `formatShortcut`, so this is a compact inline
105
+ * equivalent: on macOS, swap Ctrl→⌘, Alt→⌥, Shift→⇧ and drop the `+`.
106
+ */
107
+ function fmtShortcut(shortcut: string): string {
108
+ const isMac =
109
+ typeof navigator !== 'undefined' && /Mac|iPhone|iPad/i.test(navigator.platform || '');
110
+ if (!isMac) return shortcut;
111
+ return shortcut
112
+ .split('+')
113
+ .map((part) => {
114
+ switch (part) {
115
+ case 'Ctrl':
116
+ return '⌘';
117
+ case 'Alt':
118
+ return '⌥';
119
+ case 'Shift':
120
+ return '⇧';
121
+ default:
122
+ return part;
123
+ }
124
+ })
125
+ .join('');
126
+ }
127
+
128
+ /* ───────────────────────── inline action helpers ─────────────────────────
129
+ * Every helper below is a faithful port of the corresponding function in
130
+ * apps/web/src/shell/home-tab-actions.ts + tab-actions.ts, reimplemented over
131
+ * the SDK's `api` (CasualSheetsAPI) / `api.univer` (FUniver) so the chrome
132
+ * never imports app context. Command ids are identical to the app's.
133
+ */
134
+
135
+ function fu(api: CasualSheetsAPI): FUniver {
136
+ return api.univer;
137
+ }
138
+
139
+ function activeRange(api: CasualSheetsAPI) {
140
+ return fu(api).getActiveWorkbook()?.getActiveSheet()?.getActiveRange() ?? null;
141
+ }
142
+
143
+ function activeSheet(api: CasualSheetsAPI) {
144
+ return fu(api).getActiveWorkbook()?.getActiveSheet() ?? null;
145
+ }
146
+
147
+ /** Selection as A1 (e.g. `B2:D10`), or null. Used to seed dialog context. */
148
+ function selectionA1(api: CasualSheetsAPI): string | null {
149
+ const range = activeRange(api);
150
+ if (!range) return null;
151
+ try {
152
+ return range.getA1Notation();
153
+ } catch {
154
+ return null;
155
+ }
156
+ }
157
+
158
+ /* ── Edit ─────────────────────────────────────────────────────────────── */
159
+
160
+ const undo: RunFn = (api) => void api.executeCommand('univer.command.undo');
161
+ const redo: RunFn = (api) => void api.executeCommand('univer.command.redo');
162
+ const cut: RunFn = (api) => void api.executeCommand('univer.command.cut');
163
+ const copy: RunFn = (api) => void api.executeCommand('univer.command.copy');
164
+ const paste: RunFn = (api) => void api.executeCommand('univer.command.paste');
165
+ const pasteFormattingOnly: RunFn = (api) => void api.executeCommand('sheet.command.paste-format');
166
+
167
+ /* ── Insert (facade-driven; mirror tab-actions.ts) ─────────────────────── */
168
+
169
+ const insertRowAbove: RunFn = (api) => {
170
+ const range = activeRange(api);
171
+ const sheet = activeSheet(api);
172
+ if (!range || !sheet) return;
173
+ sheet.insertRowBefore(range.getRow());
174
+ };
175
+
176
+ const insertRowBelow: RunFn = (api) => {
177
+ const range = activeRange(api);
178
+ const sheet = activeSheet(api);
179
+ if (!range || !sheet) return;
180
+ sheet.insertRowAfter(range.getRow() + range.getHeight() - 1);
181
+ };
182
+
183
+ const insertColumnLeft: RunFn = (api) => {
184
+ const range = activeRange(api);
185
+ const sheet = activeSheet(api);
186
+ if (!range || !sheet) return;
187
+ sheet.insertColumnBefore(range.getColumn());
188
+ };
189
+
190
+ const insertColumnRight: RunFn = (api) => {
191
+ const range = activeRange(api);
192
+ const sheet = activeSheet(api);
193
+ if (!range || !sheet) return;
194
+ sheet.insertColumnAfter(range.getColumn() + range.getWidth() - 1);
195
+ };
196
+
197
+ const deleteSelectedRow: RunFn = (api) => {
198
+ const range = activeRange(api);
199
+ const sheet = activeSheet(api);
200
+ if (!range || !sheet) return;
201
+ sheet.deleteRows(range.getRow(), range.getHeight());
202
+ };
203
+
204
+ const deleteSelectedColumn: RunFn = (api) => {
205
+ const range = activeRange(api);
206
+ const sheet = activeSheet(api);
207
+ if (!range || !sheet) return;
208
+ sheet.deleteColumns(range.getColumn(), range.getWidth());
209
+ };
210
+
211
+ const insertNewSheet: RunFn = (api) => {
212
+ fu(api).getActiveWorkbook()?.insertSheet();
213
+ };
214
+
215
+ const insertImage: RunFn = (api) => void api.executeCommand('sheet.command.insert-float-image');
216
+
217
+ const insertHyperlink: RunFn = (api) =>
218
+ void api.executeCommand('sheet.operation.insert-hyper-link');
219
+
220
+ const insertComment: RunFn = (api) => void api.executeCommand('sheet.operation.show-comment-modal');
221
+
222
+ const insertTodayDate: RunFn = (api) => {
223
+ const range = activeRange(api);
224
+ if (!range) return;
225
+ const today = new Date();
226
+ const pad = (n: number) => n.toString().padStart(2, '0');
227
+ const v = `${today.getFullYear()}-${pad(today.getMonth() + 1)}-${pad(today.getDate())}`;
228
+ range.setValue({ v });
229
+ };
230
+
231
+ const insertCurrentTime: RunFn = (api) => {
232
+ const range = activeRange(api);
233
+ if (!range) return;
234
+ const now = new Date();
235
+ const pad = (n: number) => n.toString().padStart(2, '0');
236
+ const v = `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`;
237
+ range.setValue({ v });
238
+ };
239
+
240
+ const insertTable: RunFn = (api) => {
241
+ void (async () => {
242
+ const range = activeRange(api);
243
+ const sheet = activeSheet(api);
244
+ const wb = fu(api).getActiveWorkbook();
245
+ if (!range || !sheet || !wb) return;
246
+ await ensurePluginByName('table');
247
+ await api.executeCommand('sheet.command.add-table', {
248
+ unitId: wb.getId(),
249
+ subUnitId: sheet.getSheetId(),
250
+ range: {
251
+ startRow: range.getRow(),
252
+ startColumn: range.getColumn(),
253
+ endRow: range.getRow() + range.getHeight() - 1,
254
+ endColumn: range.getColumn() + range.getWidth() - 1,
255
+ },
256
+ });
257
+ })();
258
+ };
259
+
260
+ const autoFitColumns: RunFn = (api) => {
261
+ const range = activeRange(api);
262
+ const sheet = activeSheet(api);
263
+ if (!range || !sheet) return;
264
+ const withAutoWidth = sheet as unknown as {
265
+ setColumnAutoWidth?: (col: number, n: number) => unknown;
266
+ };
267
+ withAutoWidth.setColumnAutoWidth?.(range.getColumn(), range.getWidth());
268
+ };
269
+
270
+ const AUTO_FIT_ROW_CAP = 500;
271
+ const autoFitRows: RunFn = (api) => {
272
+ const range = activeRange(api);
273
+ const sheet = activeSheet(api);
274
+ if (!range || !sheet) return;
275
+ const start = range.getRow();
276
+ const count = Math.min(range.getHeight(), AUTO_FIT_ROW_CAP);
277
+ for (let r = 0; r < count; r++) sheet.autoFitRow(start + r);
278
+ };
279
+
280
+ /* ── Format ───────────────────────────────────────────────────────────── */
281
+
282
+ // Patterns copied from home-tab-actions.ts NUMBER_FORMAT_PATTERNS.
283
+ const NUMBER_FORMAT_PATTERNS = {
284
+ general: '',
285
+ number: '#,##0.00',
286
+ integer: '#,##0',
287
+ currency: '"$"#,##0.00',
288
+ accounting: '_("$"* #,##0.00_);_("$"* (#,##0.00);_("$"* "-"??_);_(@_)',
289
+ percent: '0.00%',
290
+ date: 'yyyy-mm-dd',
291
+ time: 'hh:mm:ss',
292
+ scientific: '0.00E+00',
293
+ text: '@',
294
+ } as const;
295
+ type NumberFormatKey = keyof typeof NUMBER_FORMAT_PATTERNS;
296
+
297
+ const NUM_FORMAT_ORDER: NumberFormatKey[] = [
298
+ 'general',
299
+ 'number',
300
+ 'integer',
301
+ 'currency',
302
+ 'accounting',
303
+ 'percent',
304
+ 'date',
305
+ 'time',
306
+ 'scientific',
307
+ 'text',
308
+ ];
309
+
310
+ const NUM_FORMAT_SHORTCUT: Partial<Record<NumberFormatKey, string>> = {
311
+ number: 'Ctrl+Shift+1',
312
+ time: 'Ctrl+Shift+2',
313
+ date: 'Ctrl+Shift+3',
314
+ currency: 'Ctrl+Shift+4',
315
+ percent: 'Ctrl+Shift+5',
316
+ scientific: 'Ctrl+Shift+6',
317
+ };
318
+
319
+ function setNumberFormatByKey(api: CasualSheetsAPI, key: NumberFormatKey) {
320
+ // setNumberFormat lives on the sheets-numfmt facade extension (runtime cast).
321
+ const range = activeRange(api) as unknown as { setNumberFormat?: (p: string) => unknown } | null;
322
+ range?.setNumberFormat?.(NUMBER_FORMAT_PATTERNS[key]);
323
+ }
324
+
325
+ const increaseDecimal: RunFn = (api) =>
326
+ void api.executeCommand('sheet.command.numfmt.add.decimal.command');
327
+ const decreaseDecimal: RunFn = (api) =>
328
+ void api.executeCommand('sheet.command.numfmt.subtract.decimal.command');
329
+ const clearFormat: RunFn = (api) => void api.executeCommand('sheet.command.clear-selection-format');
330
+
331
+ function applyBorders(api: CasualSheetsAPI, choice: 'all' | 'outside' | 'none') {
332
+ const range = activeRange(api);
333
+ if (!range) return;
334
+ const type =
335
+ choice === 'all' ? BorderType.ALL : choice === 'outside' ? BorderType.OUTSIDE : BorderType.NONE;
336
+ const style = choice === 'none' ? BorderStyleTypes.NONE : BorderStyleTypes.THIN;
337
+ range.setBorder(type, style, '#000000');
338
+ }
339
+
340
+ /* ── Format → Visibility (command-driven; mirror tab-actions.ts) ───────── */
341
+
342
+ function rowSpan(api: CasualSheetsAPI) {
343
+ const wb = fu(api).getActiveWorkbook();
344
+ const sheet = activeSheet(api);
345
+ const range = activeRange(api);
346
+ if (!wb || !sheet || !range) return null;
347
+ const startRow = range.getRow();
348
+ const endRow = startRow + range.getHeight() - 1;
349
+ const maxCol = (sheet as unknown as { getMaxColumns?: () => number }).getMaxColumns?.() ?? 1;
350
+ return { wb, sheet, startRow, endRow, maxCol };
351
+ }
352
+
353
+ function colSpan(api: CasualSheetsAPI) {
354
+ const wb = fu(api).getActiveWorkbook();
355
+ const sheet = activeSheet(api);
356
+ const range = activeRange(api);
357
+ if (!wb || !sheet || !range) return null;
358
+ const startColumn = range.getColumn();
359
+ const endColumn = startColumn + range.getWidth() - 1;
360
+ const maxRow = (sheet as unknown as { getMaxRows?: () => number }).getMaxRows?.() ?? 1;
361
+ return { wb, sheet, startColumn, endColumn, maxRow };
362
+ }
363
+
364
+ const hideSelectedRows: RunFn = (api) => {
365
+ const s = rowSpan(api);
366
+ if (!s) return;
367
+ void api.executeCommand('sheet.command.set-rows-hidden', {
368
+ unitId: s.wb.getId(),
369
+ subUnitId: s.sheet.getSheetId(),
370
+ ranges: [
371
+ {
372
+ startRow: s.startRow,
373
+ endRow: s.endRow,
374
+ startColumn: 0,
375
+ endColumn: Math.max(0, s.maxCol - 1),
376
+ rangeType: 1,
377
+ },
378
+ ],
379
+ });
380
+ };
381
+
382
+ const unhideSelectedRows: RunFn = (api) => {
383
+ const s = rowSpan(api);
384
+ if (!s) return;
385
+ void api.executeCommand('sheet.command.set-specific-rows-visible', {
386
+ unitId: s.wb.getId(),
387
+ subUnitId: s.sheet.getSheetId(),
388
+ ranges: [
389
+ {
390
+ startRow: s.startRow,
391
+ endRow: s.endRow,
392
+ startColumn: 0,
393
+ endColumn: Math.max(0, s.maxCol - 1),
394
+ rangeType: 1,
395
+ },
396
+ ],
397
+ });
398
+ };
399
+
400
+ const hideSelectedColumns: RunFn = (api) => {
401
+ const s = colSpan(api);
402
+ if (!s) return;
403
+ void api.executeCommand('sheet.command.set-col-hidden', {
404
+ unitId: s.wb.getId(),
405
+ subUnitId: s.sheet.getSheetId(),
406
+ ranges: [
407
+ {
408
+ startRow: 0,
409
+ endRow: Math.max(0, s.maxRow - 1),
410
+ startColumn: s.startColumn,
411
+ endColumn: s.endColumn,
412
+ rangeType: 2,
413
+ },
414
+ ],
415
+ });
416
+ };
417
+
418
+ const unhideSelectedColumns: RunFn = (api) => {
419
+ const s = colSpan(api);
420
+ if (!s) return;
421
+ // Univer's "show specific cols" id is asymmetric with the row variant.
422
+ void api.executeCommand('sheet.command.set-col-visible-on-cols', {
423
+ unitId: s.wb.getId(),
424
+ subUnitId: s.sheet.getSheetId(),
425
+ ranges: [
426
+ {
427
+ startRow: 0,
428
+ endRow: Math.max(0, s.maxRow - 1),
429
+ startColumn: s.startColumn,
430
+ endColumn: s.endColumn,
431
+ rangeType: 2,
432
+ },
433
+ ],
434
+ });
435
+ };
436
+
437
+ /* ── View ─────────────────────────────────────────────────────────────── */
438
+
439
+ type FreezeCapableSheet = {
440
+ setFrozenRows: (n: number) => unknown;
441
+ setFrozenColumns: (n: number) => unknown;
442
+ };
443
+
444
+ const freezeFirstRow: RunFn = (api) => {
445
+ (activeSheet(api) as unknown as FreezeCapableSheet | null)?.setFrozenRows(1);
446
+ };
447
+ const freezeFirstColumn: RunFn = (api) => {
448
+ (activeSheet(api) as unknown as FreezeCapableSheet | null)?.setFrozenColumns(1);
449
+ };
450
+ const freezeAtSelection: RunFn = (api) =>
451
+ void api.executeCommand('sheet.command.set-selection-frozen');
452
+ const unfreezePanes: RunFn = (api) => void api.executeCommand('sheet.command.cancel-frozen');
453
+
454
+ const toggleGridlines: RunFn = (api) => {
455
+ const wb = fu(api).getActiveWorkbook();
456
+ const sheet = activeSheet(api);
457
+ if (!wb || !sheet) return;
458
+ // BooleanNumber: 0 = hide, 1 = show. The app reads current state; without a
459
+ // reactive UI store here we hide (matches the app's default click path).
460
+ void api.executeCommand('sheet.command.toggle-gridlines', {
461
+ unitId: wb.getId(),
462
+ subUnitId: sheet.getSheetId(),
463
+ showGridlines: 0,
464
+ });
465
+ };
466
+
467
+ const toggleCommentPanel: RunFn = (api) =>
468
+ void api.executeCommand('sheet.operation.toggle-comment-panel');
469
+
470
+ const jumpToFirstCell: RunFn = (api) => {
471
+ activeSheet(api)?.getRange(0, 0).activate();
472
+ };
473
+
474
+ const switchToPreviousSheet: RunFn = (api) => switchSheetByDelta(api, -1);
475
+ const switchToNextSheet: RunFn = (api) => switchSheetByDelta(api, +1);
476
+
477
+ function switchSheetByDelta(api: CasualSheetsAPI, delta: -1 | 1) {
478
+ const wb = fu(api).getActiveWorkbook();
479
+ const active = wb?.getActiveSheet();
480
+ if (!wb || !active) return;
481
+ const sheets = wb.getSheets();
482
+ const activeId = active.getSheetId();
483
+ const idx = sheets.findIndex((s) => s.getSheetId() === activeId);
484
+ if (idx < 0) return;
485
+ const nextIdx = idx + delta;
486
+ if (nextIdx < 0 || nextIdx >= sheets.length) return;
487
+ wb.setActiveSheet(sheets[nextIdx]);
35
488
  }
36
489
 
37
- // All command ids below verified via:
38
- // grep -rhoE "id: '(sheet\.command\.[a-z.-]+)'" vendor/univer-revamp/packages | sort -u
39
- // (undo/redo: univer.command.undo / .redo in packages/core undoredo)
40
- const MENUS: Menu[] = [
490
+ const showFormulas: RunFn = (api) => void api.executeCommand('sheet.command.set-show-formula', {});
491
+
492
+ /* ── Data ─────────────────────────────────────────────────────────────── */
493
+
494
+ const sortAsc: RunFn = (api) => sortRange(api, true);
495
+ const sortDesc: RunFn = (api) => sortRange(api, false);
496
+
497
+ function sortRange(api: CasualSheetsAPI, ascending: boolean) {
498
+ const range = activeRange(api);
499
+ if (!range) return;
500
+ // sort() comes from the sheets-sort facade extension (runtime cast).
501
+ const withSort = range as unknown as {
502
+ sort?: (spec: { column: number; ascending: boolean }) => unknown;
503
+ };
504
+ withSort.sort?.({ column: range.getColumn(), ascending });
505
+ }
506
+
507
+ const toggleFilter: RunFn = (api) => {
508
+ void (async () => {
509
+ await ensurePluginByName('filter');
510
+ const wb = fu(api).getActiveWorkbook();
511
+ const sheet = activeSheet(api);
512
+ const range = activeRange(api);
513
+ if (!wb || !sheet || !range) return;
514
+ const sheetWithFilter = sheet as unknown as { getFilter?: () => unknown };
515
+ if (sheetWithFilter.getFilter?.()) {
516
+ await api.executeCommand('sheet.command.remove-sheet-filter', {
517
+ unitId: wb.getId(),
518
+ subUnitId: sheet.getSheetId(),
519
+ });
520
+ return;
521
+ }
522
+ await api.executeCommand('sheet.command.set-filter-range', {
523
+ unitId: wb.getId(),
524
+ subUnitId: sheet.getSheetId(),
525
+ range: {
526
+ startRow: range.getRow(),
527
+ startColumn: range.getColumn(),
528
+ endRow: range.getRow() + range.getHeight() - 1,
529
+ endColumn: range.getColumn() + range.getWidth() - 1,
530
+ },
531
+ });
532
+ })();
533
+ };
534
+
535
+ const splitTextToColumns: RunFn = (api) =>
536
+ void api.executeCommand('sheet.command.split-text-to-columns');
537
+
538
+ const forceRecalculate: RunFn = (api) =>
539
+ void api.executeCommand('formula.mutation.set-formula-calculation-start', {
540
+ forceCalculation: true,
541
+ });
542
+
543
+ /* ───────────────────────────── menu structure ─────────────────────────── */
544
+
545
+ // Mirrors the app's menu order. Dialog items resolve `dialog` against
546
+ // `onDialogRequest`; non-dialog items dispatch via the inline helpers above.
547
+ const MENUS: MenuDef[] = [
548
+ {
549
+ id: 'file',
550
+ label: 'File',
551
+ feature: 'file',
552
+ items: [
553
+ { kind: 'item', id: 'properties', label: 'Properties…', icon: 'info', dialog: 'properties' },
554
+ {
555
+ kind: 'item',
556
+ id: 'about',
557
+ label: 'About casual sheets',
558
+ icon: 'help_outline',
559
+ dialog: 'about',
560
+ },
561
+ ],
562
+ },
41
563
  {
42
564
  id: 'edit',
43
565
  label: 'Edit',
44
566
  items: [
45
- { id: 'undo', label: 'Undo', command: 'univer.command.undo', icon: 'undo' },
46
- { id: 'redo', label: 'Redo', command: 'univer.command.redo', icon: 'redo' },
47
- { id: 'cut', label: 'Cut', command: 'univer.command.cut', icon: 'content_cut' },
48
- { id: 'copy', label: 'Copy', command: 'univer.command.copy', icon: 'content_copy' },
49
- { id: 'paste', label: 'Paste', command: 'univer.command.paste', icon: 'content_paste' },
567
+ { kind: 'item', id: 'undo', label: 'Undo', icon: 'undo', shortcut: 'Ctrl+Z', run: undo },
568
+ { kind: 'item', id: 'redo', label: 'Redo', icon: 'redo', shortcut: 'Ctrl+Y', run: redo },
569
+ { kind: 'separator', id: 'sep-clip' },
570
+ { kind: 'item', id: 'cut', label: 'Cut', icon: 'content_cut', shortcut: 'Ctrl+X', run: cut },
571
+ {
572
+ kind: 'item',
573
+ id: 'copy',
574
+ label: 'Copy',
575
+ icon: 'content_copy',
576
+ shortcut: 'Ctrl+C',
577
+ run: copy,
578
+ },
579
+ {
580
+ kind: 'item',
581
+ id: 'paste',
582
+ label: 'Paste',
583
+ icon: 'content_paste',
584
+ shortcut: 'Ctrl+V',
585
+ run: paste,
586
+ },
587
+ {
588
+ kind: 'item',
589
+ id: 'paste-format',
590
+ label: 'Paste formatting only',
591
+ icon: 'content_paste',
592
+ shortcut: 'Ctrl+Shift+V',
593
+ run: pasteFormattingOnly,
594
+ },
595
+ {
596
+ kind: 'item',
597
+ id: 'paste-special',
598
+ label: 'Paste Special…',
599
+ icon: 'content_paste_go',
600
+ shortcut: 'Ctrl+Alt+V',
601
+ dialog: 'paste-special',
602
+ },
603
+ { kind: 'separator', id: 'sep-find' },
604
+ {
605
+ kind: 'item',
606
+ id: 'find-replace',
607
+ label: 'Find & Replace…',
608
+ icon: 'search',
609
+ shortcut: 'Ctrl+F',
610
+ dialog: 'find-replace',
611
+ },
612
+ { kind: 'separator', id: 'sep-cells' },
613
+ {
614
+ kind: 'item',
615
+ id: 'edit-insert-cells',
616
+ label: 'Insert cells…',
617
+ icon: 'add_box',
618
+ shortcut: 'Ctrl++',
619
+ dialog: 'insert-cells',
620
+ },
621
+ {
622
+ kind: 'item',
623
+ id: 'edit-delete-cells',
624
+ label: 'Delete cells…',
625
+ icon: 'indeterminate_check_box',
626
+ shortcut: 'Ctrl+-',
627
+ dialog: 'delete-cells',
628
+ },
629
+ ],
630
+ },
631
+ {
632
+ id: 'view',
633
+ label: 'View',
634
+ items: [
635
+ {
636
+ kind: 'item',
637
+ id: 'show-formulas',
638
+ label: 'Show formulas',
639
+ icon: 'description',
640
+ shortcut: 'Ctrl+`',
641
+ run: showFormulas,
642
+ },
643
+ {
644
+ kind: 'item',
645
+ id: 'toggle-gridlines',
646
+ label: 'Toggle gridlines',
647
+ icon: 'grid_on',
648
+ run: toggleGridlines,
649
+ },
650
+ { kind: 'separator', id: 'sep-freeze' },
651
+ {
652
+ kind: 'item',
653
+ id: 'freeze-row',
654
+ label: 'Freeze top row',
655
+ icon: 'border_horizontal',
656
+ run: freezeFirstRow,
657
+ },
658
+ {
659
+ kind: 'item',
660
+ id: 'freeze-col',
661
+ label: 'Freeze first column',
662
+ icon: 'border_vertical',
663
+ run: freezeFirstColumn,
664
+ },
665
+ {
666
+ kind: 'item',
667
+ id: 'freeze-selection',
668
+ label: 'Freeze panes (at selection)',
669
+ icon: 'grid_4x4',
670
+ run: freezeAtSelection,
671
+ },
672
+ { kind: 'item', id: 'unfreeze', label: 'Unfreeze', icon: 'grid_off', run: unfreezePanes },
673
+ { kind: 'separator', id: 'sep-nav' },
674
+ {
675
+ kind: 'item',
676
+ id: 'jump-home',
677
+ label: 'Jump to A1',
678
+ icon: 'home',
679
+ shortcut: 'Ctrl+Home',
680
+ run: jumpToFirstCell,
681
+ },
682
+ {
683
+ kind: 'item',
684
+ id: 'prev-sheet',
685
+ label: 'Previous sheet',
686
+ icon: 'navigate_before',
687
+ shortcut: 'Ctrl+PageUp',
688
+ run: switchToPreviousSheet,
689
+ },
690
+ {
691
+ kind: 'item',
692
+ id: 'next-sheet',
693
+ label: 'Next sheet',
694
+ icon: 'navigate_next',
695
+ shortcut: 'Ctrl+PageDown',
696
+ run: switchToNextSheet,
697
+ },
698
+ { kind: 'separator', id: 'sep-panels' },
699
+ {
700
+ kind: 'item',
701
+ id: 'comments-panel',
702
+ label: 'Comments panel',
703
+ icon: 'forum',
704
+ run: toggleCommentPanel,
705
+ },
50
706
  ],
51
707
  },
52
708
  {
@@ -54,28 +710,140 @@ const MENUS: Menu[] = [
54
710
  label: 'Insert',
55
711
  items: [
56
712
  {
57
- id: 'insert-row',
58
- label: 'Insert row above',
59
- command: 'sheet.command.insert-row-before',
60
- icon: 'add_row_above',
713
+ kind: 'item',
714
+ id: 'new-sheet',
715
+ label: 'New sheet',
716
+ icon: 'add_box',
717
+ shortcut: 'Shift+F11',
718
+ run: insertNewSheet,
61
719
  },
62
720
  {
63
- id: 'insert-col',
64
- label: 'Insert column left',
65
- command: 'sheet.command.insert-col-before',
66
- icon: 'add_column_left',
721
+ kind: 'item',
722
+ id: 'insert-table',
723
+ label: 'Table',
724
+ icon: 'table_rows',
725
+ shortcut: 'Ctrl+L',
726
+ run: insertTable,
727
+ feature: 'tables',
67
728
  },
68
729
  {
69
- id: 'delete-row',
70
- label: 'Delete row',
71
- command: 'sheet.command.remove-row',
72
- icon: 'delete',
730
+ kind: 'item',
731
+ id: 'insert-chart',
732
+ label: 'Chart…',
733
+ icon: 'bar_chart',
734
+ dialog: 'insert-chart',
735
+ feature: 'charts',
73
736
  },
74
737
  {
75
- id: 'delete-col',
76
- label: 'Delete column',
77
- command: 'sheet.command.remove-col',
78
- icon: 'delete',
738
+ kind: 'item',
739
+ id: 'insert-sparkline',
740
+ label: 'Sparkline…',
741
+ icon: 'show_chart',
742
+ dialog: 'insert-sparkline',
743
+ feature: 'sparklines',
744
+ },
745
+ {
746
+ kind: 'item',
747
+ id: 'insert-pivot',
748
+ label: 'PivotTable…',
749
+ icon: 'pivot_table_chart',
750
+ dialog: 'insert-pivot',
751
+ feature: 'pivots',
752
+ },
753
+ { kind: 'separator', id: 'sep-objects' },
754
+ { kind: 'item', id: 'insert-image', label: 'Image…', icon: 'image', run: insertImage },
755
+ {
756
+ kind: 'item',
757
+ id: 'insert-function',
758
+ label: 'Function…',
759
+ icon: 'functions',
760
+ shortcut: 'Shift+F3',
761
+ dialog: 'insert-function',
762
+ },
763
+ {
764
+ kind: 'item',
765
+ id: 'insert-link',
766
+ label: 'Hyperlink…',
767
+ icon: 'link',
768
+ shortcut: 'Ctrl+K',
769
+ run: insertHyperlink,
770
+ },
771
+ {
772
+ kind: 'item',
773
+ id: 'insert-comment',
774
+ label: 'Comment',
775
+ icon: 'comment',
776
+ shortcut: 'Shift+F2',
777
+ run: insertComment,
778
+ },
779
+ { kind: 'separator', id: 'sep-rowcol' },
780
+ {
781
+ kind: 'submenu',
782
+ id: 'insert-rowcol',
783
+ label: 'Rows & columns',
784
+ icon: 'grid_on',
785
+ items: [
786
+ {
787
+ kind: 'item',
788
+ id: 'insert-row-above',
789
+ label: 'Row above',
790
+ icon: 'vertical_align_top',
791
+ run: insertRowAbove,
792
+ },
793
+ {
794
+ kind: 'item',
795
+ id: 'insert-row-below',
796
+ label: 'Row below',
797
+ icon: 'vertical_align_bottom',
798
+ run: insertRowBelow,
799
+ },
800
+ {
801
+ kind: 'item',
802
+ id: 'insert-col-left',
803
+ label: 'Column left',
804
+ icon: 'keyboard_tab_rtl',
805
+ run: insertColumnLeft,
806
+ },
807
+ {
808
+ kind: 'item',
809
+ id: 'insert-col-right',
810
+ label: 'Column right',
811
+ icon: 'keyboard_tab',
812
+ run: insertColumnRight,
813
+ },
814
+ ],
815
+ },
816
+ { kind: 'separator', id: 'sep-autofit' },
817
+ {
818
+ kind: 'item',
819
+ id: 'autofit-col',
820
+ label: 'Auto-fit column width',
821
+ icon: 'settings_ethernet',
822
+ run: autoFitColumns,
823
+ },
824
+ {
825
+ kind: 'item',
826
+ id: 'autofit-row',
827
+ label: 'Auto-fit row height',
828
+ icon: 'height',
829
+ run: autoFitRows,
830
+ },
831
+ { kind: 'separator', id: 'sep-date' },
832
+ {
833
+ kind: 'item',
834
+ id: 'insert-today',
835
+ label: "Today's date",
836
+ icon: 'today',
837
+ shortcut: 'Ctrl+;',
838
+ run: insertTodayDate,
839
+ },
840
+ {
841
+ kind: 'item',
842
+ id: 'insert-time',
843
+ label: 'Current time',
844
+ icon: 'schedule',
845
+ shortcut: 'Ctrl+Shift+:',
846
+ run: insertCurrentTime,
79
847
  },
80
848
  ],
81
849
  },
@@ -83,30 +851,177 @@ const MENUS: Menu[] = [
83
851
  id: 'format',
84
852
  label: 'Format',
85
853
  items: [
86
- { id: 'bold', label: 'Bold', command: 'sheet.command.set-range-bold', icon: 'format_bold' },
87
854
  {
855
+ kind: 'item',
856
+ id: 'format-cells',
857
+ label: 'Format cells…',
858
+ icon: 'format_shapes',
859
+ shortcut: 'Ctrl+1',
860
+ dialog: 'format-cells',
861
+ },
862
+ { kind: 'separator', id: 'sep-format-cells' },
863
+ {
864
+ kind: 'item',
865
+ id: 'bold',
866
+ label: 'Bold',
867
+ icon: 'format_bold',
868
+ shortcut: 'Ctrl+B',
869
+ run: (api) => void api.executeCommand('sheet.command.set-range-bold'),
870
+ },
871
+ {
872
+ kind: 'item',
88
873
  id: 'italic',
89
874
  label: 'Italic',
90
- command: 'sheet.command.set-range-italic',
91
875
  icon: 'format_italic',
876
+ shortcut: 'Ctrl+I',
877
+ run: (api) => void api.executeCommand('sheet.command.set-range-italic'),
92
878
  },
93
879
  {
880
+ kind: 'item',
94
881
  id: 'underline',
95
882
  label: 'Underline',
96
- command: 'sheet.command.set-range-underline',
97
883
  icon: 'format_underlined',
884
+ shortcut: 'Ctrl+U',
885
+ run: (api) => void api.executeCommand('sheet.command.set-range-underline'),
98
886
  },
99
887
  {
888
+ kind: 'item',
100
889
  id: 'wrap-text',
101
890
  label: 'Wrap text',
102
- command: 'sheet.command.set-text-wrap',
103
891
  icon: 'wrap_text',
892
+ run: (api) => void api.executeCommand('sheet.command.set-text-wrap', { value: 3 }),
893
+ },
894
+ { kind: 'separator', id: 'sep-numfmt' },
895
+ {
896
+ kind: 'submenu',
897
+ id: 'num-format',
898
+ label: 'Number format',
899
+ icon: 'looks_one',
900
+ items: NUM_FORMAT_ORDER.map<MenuItemDef>((k) => ({
901
+ kind: 'item',
902
+ id: `num-${k}`,
903
+ label: k[0]!.toUpperCase() + k.slice(1),
904
+ icon: 'looks_one',
905
+ shortcut: NUM_FORMAT_SHORTCUT[k],
906
+ run: (api) => setNumberFormatByKey(api, k),
907
+ })),
908
+ },
909
+ {
910
+ kind: 'item',
911
+ id: 'decimal-up',
912
+ label: 'Increase decimals',
913
+ icon: 'decimal_increase',
914
+ run: increaseDecimal,
915
+ },
916
+ {
917
+ kind: 'item',
918
+ id: 'decimal-down',
919
+ label: 'Decrease decimals',
920
+ icon: 'decimal_decrease',
921
+ run: decreaseDecimal,
922
+ },
923
+ { kind: 'separator', id: 'sep-borders' },
924
+ {
925
+ kind: 'submenu',
926
+ id: 'borders',
927
+ label: 'Borders',
928
+ icon: 'border_all',
929
+ items: [
930
+ {
931
+ kind: 'item',
932
+ id: 'border-all',
933
+ label: 'All borders',
934
+ icon: 'border_all',
935
+ run: (api) => applyBorders(api, 'all'),
936
+ },
937
+ {
938
+ kind: 'item',
939
+ id: 'border-outside',
940
+ label: 'Outside borders',
941
+ icon: 'border_outer',
942
+ run: (api) => applyBorders(api, 'outside'),
943
+ },
944
+ {
945
+ kind: 'item',
946
+ id: 'border-none',
947
+ label: 'No border',
948
+ icon: 'border_clear',
949
+ run: (api) => applyBorders(api, 'none'),
950
+ },
951
+ ],
952
+ },
953
+ { kind: 'separator', id: 'sep-cond' },
954
+ {
955
+ kind: 'item',
956
+ id: 'conditional-formatting',
957
+ label: 'Conditional formatting…',
958
+ icon: 'palette',
959
+ dialog: 'conditional-formatting',
960
+ feature: 'conditionalFormatting',
961
+ },
962
+ { kind: 'separator', id: 'sep-visibility' },
963
+ {
964
+ kind: 'submenu',
965
+ id: 'visibility',
966
+ label: 'Visibility',
967
+ icon: 'visibility',
968
+ items: [
969
+ {
970
+ kind: 'item',
971
+ id: 'hide-row',
972
+ label: 'Hide row',
973
+ icon: 'visibility_off',
974
+ shortcut: 'Ctrl+9',
975
+ run: hideSelectedRows,
976
+ },
977
+ {
978
+ kind: 'item',
979
+ id: 'unhide-row',
980
+ label: 'Unhide row',
981
+ icon: 'visibility',
982
+ shortcut: 'Ctrl+Shift+9',
983
+ run: unhideSelectedRows,
984
+ },
985
+ {
986
+ kind: 'item',
987
+ id: 'hide-col',
988
+ label: 'Hide column',
989
+ icon: 'visibility_off',
990
+ shortcut: 'Ctrl+0',
991
+ run: hideSelectedColumns,
992
+ },
993
+ {
994
+ kind: 'item',
995
+ id: 'unhide-col',
996
+ label: 'Unhide column',
997
+ icon: 'visibility',
998
+ shortcut: 'Ctrl+Shift+0',
999
+ run: unhideSelectedColumns,
1000
+ },
1001
+ ],
104
1002
  },
1003
+ { kind: 'separator', id: 'sep-clear' },
105
1004
  {
1005
+ kind: 'item',
106
1006
  id: 'clear-format',
107
1007
  label: 'Clear formatting',
108
- command: 'sheet.command.clear-selection-format',
109
1008
  icon: 'format_clear',
1009
+ run: clearFormat,
1010
+ },
1011
+ { kind: 'separator', id: 'sep-delete' },
1012
+ {
1013
+ kind: 'item',
1014
+ id: 'delete-row',
1015
+ label: 'Delete row',
1016
+ icon: 'delete_sweep',
1017
+ run: deleteSelectedRow,
1018
+ },
1019
+ {
1020
+ kind: 'item',
1021
+ id: 'delete-col',
1022
+ label: 'Delete column',
1023
+ icon: 'folder_delete',
1024
+ run: deleteSelectedColumn,
110
1025
  },
111
1026
  ],
112
1027
  },
@@ -115,51 +1030,104 @@ const MENUS: Menu[] = [
115
1030
  label: 'Data',
116
1031
  items: [
117
1032
  {
1033
+ kind: 'item',
118
1034
  id: 'sort-asc',
119
1035
  label: 'Sort ascending',
120
- command: 'sheet.command.sort-range-asc',
121
1036
  icon: 'arrow_upward',
1037
+ run: sortAsc,
122
1038
  },
123
1039
  {
1040
+ kind: 'item',
124
1041
  id: 'sort-desc',
125
1042
  label: 'Sort descending',
126
- command: 'sheet.command.sort-range-desc',
127
1043
  icon: 'arrow_downward',
1044
+ run: sortDesc,
128
1045
  },
129
1046
  {
1047
+ kind: 'item',
1048
+ id: 'sort-custom',
1049
+ label: 'Sort range…',
1050
+ icon: 'sort',
1051
+ dialog: 'custom-sort',
1052
+ },
1053
+ {
1054
+ kind: 'item',
130
1055
  id: 'toggle-filter',
131
1056
  label: 'Toggle filter',
132
- command: 'sheet.command.smart-toggle-filter',
133
1057
  icon: 'filter_alt',
1058
+ shortcut: 'Ctrl+Shift+L',
1059
+ run: toggleFilter,
1060
+ feature: 'filter',
1061
+ },
1062
+ { kind: 'separator', id: 'sep-tools' },
1063
+ {
1064
+ kind: 'item',
1065
+ id: 'data-validation',
1066
+ label: 'Data validation…',
1067
+ icon: 'rule',
1068
+ dialog: 'data-validation',
1069
+ feature: 'dataValidation',
1070
+ },
1071
+ {
1072
+ kind: 'item',
1073
+ id: 'name-manager',
1074
+ label: 'Name Manager…',
1075
+ icon: 'bookmark_add',
1076
+ shortcut: 'Ctrl+F3',
1077
+ dialog: 'name-manager',
1078
+ },
1079
+ {
1080
+ kind: 'item',
1081
+ id: 'goal-seek',
1082
+ label: 'Goal Seek…',
1083
+ icon: 'analytics',
1084
+ dialog: 'goal-seek',
1085
+ },
1086
+ { kind: 'separator', id: 'sep-clean' },
1087
+ {
1088
+ kind: 'item',
1089
+ id: 'text-to-columns',
1090
+ label: 'Text to Columns',
1091
+ icon: 'splitscreen',
1092
+ run: splitTextToColumns,
1093
+ },
1094
+ {
1095
+ kind: 'item',
1096
+ id: 'recalculate',
1097
+ label: 'Recalculate',
1098
+ icon: 'autorenew',
1099
+ shortcut: 'F9',
1100
+ run: forceRecalculate,
134
1101
  },
135
1102
  ],
136
1103
  },
137
1104
  {
138
- id: 'view',
139
- label: 'View',
1105
+ id: 'help',
1106
+ label: 'Help',
140
1107
  items: [
141
1108
  {
142
- id: 'freeze',
143
- label: 'Freeze panes',
144
- command: 'sheet.command.set-selection-frozen',
145
- icon: 'table_view',
1109
+ kind: 'item',
1110
+ id: 'keyboard-shortcuts',
1111
+ label: 'Keyboard shortcuts',
1112
+ icon: 'info',
1113
+ shortcut: 'Ctrl+/',
1114
+ dialog: 'keyboard-shortcuts',
146
1115
  },
1116
+ { kind: 'separator', id: 'sep-help' },
1117
+ { kind: 'item', id: 'about', label: 'About casual sheets', icon: 'info', dialog: 'about' },
147
1118
  {
148
- id: 'unfreeze',
149
- label: 'Unfreeze panes',
150
- command: 'sheet.command.cancel-frozen',
151
- icon: 'grid_off',
152
- },
153
- {
154
- id: 'toggle-gridlines',
155
- label: 'Toggle gridlines',
156
- command: 'sheet.command.toggle-gridlines',
157
- icon: 'grid_on',
1119
+ kind: 'item',
1120
+ id: 'github',
1121
+ label: 'View on GitHub',
1122
+ icon: 'open_in_new',
1123
+ run: () => window.open('https://github.com/CasualOffice/sheets', '_blank'),
158
1124
  },
159
1125
  ],
160
1126
  },
161
1127
  ];
162
1128
 
1129
+ /* ───────────────────────────── styling ────────────────────────────────── */
1130
+
163
1131
  const BAR_STYLE: CSSProperties = {
164
1132
  position: 'relative',
165
1133
  display: 'flex',
@@ -191,7 +1159,7 @@ const MENU_BTN_STYLE: CSSProperties = {
191
1159
  const DROPDOWN_STYLE: CSSProperties = {
192
1160
  position: 'absolute',
193
1161
  top: '100%',
194
- minWidth: 200,
1162
+ minWidth: 220,
195
1163
  marginTop: 2,
196
1164
  padding: 4,
197
1165
  display: 'flex',
@@ -220,13 +1188,108 @@ const ITEM_STYLE: CSSProperties = {
220
1188
  textAlign: 'left',
221
1189
  };
222
1190
 
1191
+ const SEPARATOR_STYLE: CSSProperties = {
1192
+ height: 1,
1193
+ margin: '4px 6px',
1194
+ background: 'var(--cs-chrome-border, #e6e9ee)',
1195
+ };
1196
+
1197
+ const SHORTCUT_STYLE: CSSProperties = {
1198
+ marginLeft: 'auto',
1199
+ paddingLeft: 16,
1200
+ fontSize: 11,
1201
+ color: 'var(--cs-chrome-muted, #6b7280)',
1202
+ };
1203
+
1204
+ const SUBMENU_PANEL_STYLE: CSSProperties = {
1205
+ position: 'absolute',
1206
+ top: -4,
1207
+ left: '100%',
1208
+ minWidth: 200,
1209
+ marginLeft: 2,
1210
+ padding: 4,
1211
+ display: 'flex',
1212
+ flexDirection: 'column',
1213
+ border: '1px solid var(--cs-chrome-border, #e6e9ee)',
1214
+ borderRadius: 8,
1215
+ background: 'var(--cs-chrome-input-bg, #fff)',
1216
+ boxShadow: '0 6px 20px rgba(0,0,0,0.16)',
1217
+ zIndex: 1001,
1218
+ };
1219
+
1220
+ /* ───────────────────────────── filtering ──────────────────────────────── */
1221
+
1222
+ /** True when the feature gate (if any) is enabled (default: enabled). */
1223
+ function featureOn(feature: string | undefined, features: Record<string, boolean>): boolean {
1224
+ if (!feature) return true;
1225
+ return features[feature] !== false;
1226
+ }
1227
+
1228
+ /**
1229
+ * Keep an item if its feature is on AND — for a dialog item — the host provides
1230
+ * a dialog handler. Dialog items with no host hook are dropped (the SDK never
1231
+ * fakes a dialog). Submenus are filtered recursively and dropped when empty.
1232
+ */
1233
+ function keepItem(
1234
+ item: MenuItemDef,
1235
+ features: Record<string, boolean>,
1236
+ hasDialogHost: boolean,
1237
+ ): MenuItemDef | null {
1238
+ if (!featureOn(item.feature, features)) return null;
1239
+ if (item.kind === 'separator') return item;
1240
+ if (item.kind === 'submenu') {
1241
+ const items = filterItems(item.items, features, hasDialogHost);
1242
+ if (items.length === 0) return null;
1243
+ return { ...item, items };
1244
+ }
1245
+ if (item.dialog && !hasDialogHost) return null;
1246
+ return item;
1247
+ }
1248
+
1249
+ /** Filter a list and collapse leading/trailing/double separators. */
1250
+ function filterItems(
1251
+ items: MenuItemDef[],
1252
+ features: Record<string, boolean>,
1253
+ hasDialogHost: boolean,
1254
+ ): MenuItemDef[] {
1255
+ const kept = items
1256
+ .map((i) => keepItem(i, features, hasDialogHost))
1257
+ .filter((i): i is MenuItemDef => i !== null);
1258
+ // Collapse separators: drop leading, trailing, and runs.
1259
+ const out: MenuItemDef[] = [];
1260
+ for (const item of kept) {
1261
+ if (item.kind === 'separator') {
1262
+ if (out.length === 0) continue;
1263
+ if (out[out.length - 1].kind === 'separator') continue;
1264
+ }
1265
+ out.push(item);
1266
+ }
1267
+ while (out.length > 0 && out[out.length - 1].kind === 'separator') out.pop();
1268
+ return out;
1269
+ }
1270
+
1271
+ /* ───────────────────────────── component ──────────────────────────────── */
1272
+
223
1273
  export interface MenuBarProps {
224
1274
  /** Live API, or `null` until the editor is ready. */
225
1275
  api: CasualSheetsAPI | null;
1276
+ /**
1277
+ * Per-feature toggles. A control / menu whose `feature` key is `false` is not
1278
+ * rendered. Unknown / omitted keys default to enabled.
1279
+ */
1280
+ features?: Record<string, boolean>;
1281
+ /**
1282
+ * Host hook for actions the SDK can't render itself (Format Cells, Insert
1283
+ * Chart, PivotTable, Find & Replace, Insert/Delete cells, …). Called with the
1284
+ * dialog `kind` and an optional `context` (the active selection in A1 for
1285
+ * range-seeded dialogs). When omitted, those items are not rendered.
1286
+ */
1287
+ onDialogRequest?: (kind: MenuDialogKind, context?: unknown) => void;
226
1288
  }
227
1289
 
228
- export function MenuBar({ api }: MenuBarProps) {
1290
+ export function MenuBar({ api, features = {}, onDialogRequest }: MenuBarProps) {
229
1291
  const [open, setOpen] = useState<MenuId | null>(null);
1292
+ const [openSubmenu, setOpenSubmenu] = useState<string | null>(null);
230
1293
  const rootRef = useRef<HTMLDivElement>(null);
231
1294
 
232
1295
  useEffect(() => {
@@ -237,10 +1300,16 @@ export function MenuBar({ api }: MenuBarProps) {
237
1300
  useEffect(() => {
238
1301
  if (open === null) return;
239
1302
  const onKey = (e: KeyboardEvent) => {
240
- if (e.key === 'Escape') setOpen(null);
1303
+ if (e.key === 'Escape') {
1304
+ setOpen(null);
1305
+ setOpenSubmenu(null);
1306
+ }
241
1307
  };
242
1308
  const onDown = (e: PointerEvent) => {
243
- if (!rootRef.current?.contains(e.target as Node)) setOpen(null);
1309
+ if (!rootRef.current?.contains(e.target as Node)) {
1310
+ setOpen(null);
1311
+ setOpenSubmenu(null);
1312
+ }
244
1313
  };
245
1314
  document.addEventListener('keydown', onKey);
246
1315
  document.addEventListener('pointerdown', onDown, true);
@@ -250,11 +1319,106 @@ export function MenuBar({ api }: MenuBarProps) {
250
1319
  };
251
1320
  }, [open]);
252
1321
 
253
- const run = (item: MenuItem) => {
1322
+ const close = () => {
254
1323
  setOpen(null);
255
- void api?.executeCommand(item.command, item.params);
1324
+ setOpenSubmenu(null);
1325
+ };
1326
+
1327
+ const runItem = (item: Extract<MenuItemDef, { kind: 'item' }>) => {
1328
+ close();
1329
+ if (!api) return;
1330
+ if (item.dialog) {
1331
+ // Range-seeded dialogs get the current selection in A1 as context.
1332
+ const seeded =
1333
+ item.dialog === 'insert-chart' ||
1334
+ item.dialog === 'insert-pivot' ||
1335
+ item.dialog === 'insert-sparkline' ||
1336
+ item.dialog === 'insert-cells' ||
1337
+ item.dialog === 'delete-cells';
1338
+ onDialogRequest?.(item.dialog, seeded ? selectionA1(api) : undefined);
1339
+ return;
1340
+ }
1341
+ item.run?.(api);
256
1342
  };
257
1343
 
1344
+ // Compute the visible menus once per render (feature + dialog-host gating).
1345
+ const hasDialogHost = !!onDialogRequest;
1346
+ const visibleMenus = MENUS.map((menu) => ({
1347
+ ...menu,
1348
+ items: filterItems(menu.items, features, hasDialogHost),
1349
+ })).filter((menu) => featureOn(menu.feature, features) && menu.items.length > 0);
1350
+
1351
+ const renderItems = (items: MenuItemDef[]): ReactNode =>
1352
+ items.map((item) => {
1353
+ if (item.kind === 'separator') {
1354
+ return <div key={item.id} style={SEPARATOR_STYLE} role="separator" aria-hidden />;
1355
+ }
1356
+ if (item.kind === 'submenu') {
1357
+ const isSubOpen = openSubmenu === item.id;
1358
+ return (
1359
+ <div
1360
+ key={item.id}
1361
+ style={{ position: 'relative' }}
1362
+ onMouseEnter={() => setOpenSubmenu(item.id)}
1363
+ onMouseLeave={() => setOpenSubmenu((cur) => (cur === item.id ? null : cur))}
1364
+ >
1365
+ <button
1366
+ type="button"
1367
+ role="menuitem"
1368
+ data-testid={`cs-menuitem-${item.id}`}
1369
+ aria-haspopup="menu"
1370
+ aria-expanded={isSubOpen}
1371
+ disabled={!api}
1372
+ style={{ ...ITEM_STYLE, opacity: api ? 1 : 0.5 }}
1373
+ onMouseDown={(e) => e.preventDefault()}
1374
+ >
1375
+ {item.icon ? (
1376
+ <Icon name={item.icon} size={18} />
1377
+ ) : (
1378
+ <span style={{ width: 18 }} aria-hidden />
1379
+ )}
1380
+ <span>{item.label}</span>
1381
+ <Icon name="chevron_right" size={18} style={{ marginLeft: 'auto' }} />
1382
+ </button>
1383
+ {isSubOpen && (
1384
+ <div style={SUBMENU_PANEL_STYLE} role="menu" aria-label={item.label}>
1385
+ {renderItems(item.items)}
1386
+ </div>
1387
+ )}
1388
+ </div>
1389
+ );
1390
+ }
1391
+ return (
1392
+ <button
1393
+ key={item.id}
1394
+ type="button"
1395
+ role="menuitem"
1396
+ data-testid={`cs-menuitem-${item.id}`}
1397
+ disabled={!api}
1398
+ style={{ ...ITEM_STYLE, opacity: api ? 1 : 0.5 }}
1399
+ onMouseDown={(e) => {
1400
+ e.preventDefault();
1401
+ runItem(item);
1402
+ }}
1403
+ onMouseEnter={(e) => {
1404
+ setOpenSubmenu(null);
1405
+ e.currentTarget.style.background = 'var(--cs-chrome-hover, rgba(0,0,0,0.06))';
1406
+ }}
1407
+ onMouseLeave={(e) => {
1408
+ e.currentTarget.style.background = 'transparent';
1409
+ }}
1410
+ >
1411
+ {item.icon ? (
1412
+ <Icon name={item.icon} size={18} />
1413
+ ) : (
1414
+ <span style={{ width: 18 }} aria-hidden />
1415
+ )}
1416
+ <span>{item.label}</span>
1417
+ {item.shortcut && <span style={SHORTCUT_STYLE}>{fmtShortcut(item.shortcut)}</span>}
1418
+ </button>
1419
+ );
1420
+ });
1421
+
258
1422
  return (
259
1423
  <div
260
1424
  ref={rootRef}
@@ -263,13 +1427,14 @@ export function MenuBar({ api }: MenuBarProps) {
263
1427
  role="menubar"
264
1428
  aria-label="Menu bar"
265
1429
  >
266
- {MENUS.map((menu) => {
1430
+ {visibleMenus.map((menu) => {
267
1431
  const isOpen = open === menu.id;
268
1432
  return (
269
1433
  <div key={menu.id} style={{ position: 'relative' }}>
270
1434
  <button
271
1435
  type="button"
272
1436
  data-menu={menu.id}
1437
+ data-testid={`cs-menu-${menu.id}`}
273
1438
  aria-haspopup="menu"
274
1439
  aria-expanded={isOpen}
275
1440
  style={{
@@ -281,12 +1446,15 @@ export function MenuBar({ api }: MenuBarProps) {
281
1446
  // toggle the menu open/closed.
282
1447
  onMouseDown={(e) => {
283
1448
  e.preventDefault();
1449
+ setOpenSubmenu(null);
284
1450
  setOpen((cur) => (cur === menu.id ? null : menu.id));
285
1451
  }}
286
1452
  onMouseEnter={(e) => {
287
1453
  // Hover-to-switch once a menu is already open (Office behaviour).
288
- if (open !== null && open !== menu.id) setOpen(menu.id);
289
- else if (!isOpen)
1454
+ if (open !== null && open !== menu.id) {
1455
+ setOpen(menu.id);
1456
+ setOpenSubmenu(null);
1457
+ } else if (!isOpen)
290
1458
  e.currentTarget.style.background = 'var(--cs-chrome-hover, rgba(0,0,0,0.06))';
291
1459
  }}
292
1460
  onMouseLeave={(e) => {
@@ -299,33 +1467,7 @@ export function MenuBar({ api }: MenuBarProps) {
299
1467
  </button>
300
1468
  {isOpen && (
301
1469
  <div style={DROPDOWN_STYLE} role="menu" aria-label={menu.label}>
302
- {menu.items.map((item) => (
303
- <button
304
- key={item.id}
305
- type="button"
306
- role="menuitem"
307
- data-testid={`cs-menuitem-${item.id}`}
308
- disabled={!api}
309
- style={{ ...ITEM_STYLE, opacity: api ? 1 : 0.5 }}
310
- onMouseDown={(e) => {
311
- e.preventDefault();
312
- run(item);
313
- }}
314
- onMouseEnter={(e) => {
315
- e.currentTarget.style.background = 'var(--cs-chrome-hover, rgba(0,0,0,0.06))';
316
- }}
317
- onMouseLeave={(e) => {
318
- e.currentTarget.style.background = 'transparent';
319
- }}
320
- >
321
- {item.icon ? (
322
- <Icon name={item.icon} size={18} />
323
- ) : (
324
- <span style={{ width: 18 }} aria-hidden />
325
- )}
326
- <span>{item.label}</span>
327
- </button>
328
- ))}
1470
+ {renderItems(menu.items)}
329
1471
  </div>
330
1472
  )}
331
1473
  </div>