@casualoffice/sheets 0.13.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.
- package/dist/chrome.cjs +1568 -283
- package/dist/chrome.cjs.map +1 -1
- package/dist/chrome.d.cts +35 -2
- package/dist/chrome.d.ts +35 -2
- package/dist/chrome.js +1566 -291
- package/dist/chrome.js.map +1 -1
- package/dist/embed/embed-runtime.js +131 -131
- package/dist/embed.cjs +3 -3
- package/dist/embed.cjs.map +1 -1
- package/dist/embed.js +3 -3
- package/dist/embed.js.map +1 -1
- package/dist/index.cjs +3 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +3 -3
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/chrome/MenuBar.tsx +1246 -104
- package/src/chrome/Toolbar.tsx +546 -216
- package/src/embed/EmbedTransport.ts +3 -3
- package/src/embed-runtime/index.tsx +56 -8
package/src/chrome/MenuBar.tsx
CHANGED
|
@@ -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
|
|
5
|
-
* editor with its own bar):
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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.
|
|
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' | '
|
|
41
|
+
type MenuId = 'file' | 'edit' | 'view' | 'insert' | 'format' | 'data' | 'help';
|
|
21
42
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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',
|
|
46
|
-
{ id: 'redo', label: 'Redo',
|
|
47
|
-
{
|
|
48
|
-
{ id: '
|
|
49
|
-
{
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
icon: '
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
icon: '
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
icon: '
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
icon: '
|
|
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: '
|
|
139
|
-
label: '
|
|
1105
|
+
id: 'help',
|
|
1106
|
+
label: 'Help',
|
|
140
1107
|
items: [
|
|
141
1108
|
{
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
icon: '
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
icon: '
|
|
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:
|
|
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')
|
|
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))
|
|
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
|
|
1322
|
+
const close = () => {
|
|
254
1323
|
setOpen(null);
|
|
255
|
-
|
|
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
|
-
{
|
|
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)
|
|
289
|
-
|
|
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
|
|
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>
|