@casualoffice/sheets 0.16.0 → 0.17.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 +82 -63
- package/dist/chrome.cjs.map +1 -1
- package/dist/chrome.d.cts +33 -0
- package/dist/chrome.d.ts +33 -0
- package/dist/chrome.js +82 -63
- package/dist/chrome.js.map +1 -1
- package/dist/embed/embed-runtime.js +107 -107
- package/package.json +1 -1
- package/src/chrome/MenuBar.feature-gate.unit.test.ts +152 -0
- package/src/chrome/MenuBar.tsx +36 -141
- package/src/chrome/menu-model.ts +191 -0
package/package.json
CHANGED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright 2026 Casual Office
|
|
3
|
+
*
|
|
4
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
* you may not use this file except in compliance with the License.
|
|
6
|
+
* You may obtain a copy of the License at
|
|
7
|
+
*
|
|
8
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
*
|
|
10
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
* See the License for the specific language governing permissions and
|
|
14
|
+
* limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Unit tests for the MenuBar feature-gating contract — specifically the
|
|
19
|
+
* embed-mode gates that let a host hide the editor's branding surfaces:
|
|
20
|
+
*
|
|
21
|
+
* features={{ help: false }} → the whole Help menu is dropped.
|
|
22
|
+
* features={{ branding: false }} → the "View on GitHub" + "About" links are
|
|
23
|
+
* dropped (from both Help and File), so an
|
|
24
|
+
* embedded sheet shows no editor branding,
|
|
25
|
+
* while Keyboard shortcuts stays in Help.
|
|
26
|
+
*
|
|
27
|
+
* Exercises the pure `computeVisibleMenus` resolver (no DOM needed). The
|
|
28
|
+
* `MENUS` array itself lives in `MenuBar.tsx`, which statically imports
|
|
29
|
+
* `@univerjs/core` values the vendored ESM can't expose as named exports to
|
|
30
|
+
* node's loader — so it can't be imported here. Instead this pins the resolver
|
|
31
|
+
* against a fixture that mirrors the real Help/File branding subtree (kept in
|
|
32
|
+
* sync with MenuBar.tsx: Help menu `feature: 'help'`; About + View-on-GitHub +
|
|
33
|
+
* their separator `feature: 'branding'`; File > About `feature: 'branding'`).
|
|
34
|
+
*
|
|
35
|
+
* Run with: `pnpm test:unit`
|
|
36
|
+
*/
|
|
37
|
+
import { strict as assert } from 'node:assert';
|
|
38
|
+
import { test } from 'node:test';
|
|
39
|
+
|
|
40
|
+
import { computeVisibleMenus, type MenuDef } from './menu-model';
|
|
41
|
+
|
|
42
|
+
// A faithful copy of the branding-relevant subtree of `MENUS` in MenuBar.tsx.
|
|
43
|
+
const MENUS_FIXTURE: MenuDef[] = [
|
|
44
|
+
{
|
|
45
|
+
id: 'file',
|
|
46
|
+
label: 'File',
|
|
47
|
+
feature: 'file',
|
|
48
|
+
items: [
|
|
49
|
+
{ kind: 'item', id: 'properties', label: 'Properties…', dialog: 'properties' },
|
|
50
|
+
{
|
|
51
|
+
kind: 'item',
|
|
52
|
+
id: 'about',
|
|
53
|
+
label: 'About casual sheets',
|
|
54
|
+
dialog: 'about',
|
|
55
|
+
feature: 'branding',
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
id: 'help',
|
|
61
|
+
label: 'Help',
|
|
62
|
+
feature: 'help',
|
|
63
|
+
items: [
|
|
64
|
+
{
|
|
65
|
+
kind: 'item',
|
|
66
|
+
id: 'keyboard-shortcuts',
|
|
67
|
+
label: 'Keyboard shortcuts',
|
|
68
|
+
dialog: 'keyboard-shortcuts',
|
|
69
|
+
},
|
|
70
|
+
{ kind: 'separator', id: 'sep-help', feature: 'branding' },
|
|
71
|
+
{
|
|
72
|
+
kind: 'item',
|
|
73
|
+
id: 'about',
|
|
74
|
+
label: 'About casual sheets',
|
|
75
|
+
dialog: 'about',
|
|
76
|
+
feature: 'branding',
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
kind: 'item',
|
|
80
|
+
id: 'github',
|
|
81
|
+
label: 'View on GitHub',
|
|
82
|
+
run: () => {},
|
|
83
|
+
feature: 'branding',
|
|
84
|
+
},
|
|
85
|
+
],
|
|
86
|
+
},
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
// All SDK-built-in dialogs openable — matches standalone chrome (the SDK ships
|
|
90
|
+
// the About / Keyboard-shortcuts modals), so branding items are not
|
|
91
|
+
// latent-dropped and the feature gate is the only thing that can hide them.
|
|
92
|
+
const canOpenAll = () => true;
|
|
93
|
+
|
|
94
|
+
function findMenu(menus: MenuDef[], id: string) {
|
|
95
|
+
return menus.find((m) => m.id === id);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function itemIds(items: { id: string }[]): string[] {
|
|
99
|
+
return items.map((i) => i.id);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
test('default (features unset): Help menu, About and View-on-GitHub all render', () => {
|
|
103
|
+
const menus = computeVisibleMenus(MENUS_FIXTURE, {}, canOpenAll);
|
|
104
|
+
const help = findMenu(menus, 'help');
|
|
105
|
+
assert.ok(help, 'Help menu should render by default');
|
|
106
|
+
const ids = itemIds(help.items);
|
|
107
|
+
assert.ok(ids.includes('github'), 'View on GitHub should render by default');
|
|
108
|
+
assert.ok(ids.includes('about'), 'Help > About should render by default');
|
|
109
|
+
assert.ok(ids.includes('keyboard-shortcuts'), 'Keyboard shortcuts should render');
|
|
110
|
+
|
|
111
|
+
const file = findMenu(menus, 'file');
|
|
112
|
+
assert.ok(file, 'File menu should render by default');
|
|
113
|
+
assert.ok(itemIds(file.items).includes('about'), 'File > About should render by default');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('features={{ help: false }}: the whole Help menu is dropped', () => {
|
|
117
|
+
const menus = computeVisibleMenus(MENUS_FIXTURE, { help: false }, canOpenAll);
|
|
118
|
+
assert.equal(findMenu(menus, 'help'), undefined, 'Help menu must not render when help:false');
|
|
119
|
+
// File menu is unaffected by the help gate.
|
|
120
|
+
assert.ok(findMenu(menus, 'file'), 'File menu still renders when only help is disabled');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('features={{ branding: false }}: GitHub + About vanish, Help stays for shortcuts', () => {
|
|
124
|
+
const menus = computeVisibleMenus(MENUS_FIXTURE, { branding: false }, canOpenAll);
|
|
125
|
+
|
|
126
|
+
const help = findMenu(menus, 'help');
|
|
127
|
+
assert.ok(help, 'Help menu still renders (keyboard shortcuts is not branding)');
|
|
128
|
+
const helpIds = itemIds(help.items);
|
|
129
|
+
assert.ok(!helpIds.includes('github'), 'View on GitHub must be hidden when branding:false');
|
|
130
|
+
assert.ok(!helpIds.includes('about'), 'Help > About must be hidden when branding:false');
|
|
131
|
+
assert.ok(helpIds.includes('keyboard-shortcuts'), 'Keyboard shortcuts stays');
|
|
132
|
+
// Trailing separator before the (now removed) branding items must be collapsed.
|
|
133
|
+
assert.ok(!helpIds.includes('sep-help'), 'dangling separator collapses once branding items go');
|
|
134
|
+
|
|
135
|
+
const file = findMenu(menus, 'file');
|
|
136
|
+
assert.ok(file, 'File menu still renders');
|
|
137
|
+
assert.ok(
|
|
138
|
+
!itemIds(file.items).includes('about'),
|
|
139
|
+
'File > About must be hidden when branding:false',
|
|
140
|
+
);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('features={{ help: false, branding: false }}: no editor-branded surface at all', () => {
|
|
144
|
+
const menus = computeVisibleMenus(MENUS_FIXTURE, { help: false, branding: false }, canOpenAll);
|
|
145
|
+
assert.equal(findMenu(menus, 'help'), undefined, 'Help menu gone');
|
|
146
|
+
const file = findMenu(menus, 'file');
|
|
147
|
+
assert.ok(file && !itemIds(file.items).includes('about'), 'File > About gone');
|
|
148
|
+
// No visible menu anywhere links to GitHub.
|
|
149
|
+
for (const m of menus) {
|
|
150
|
+
assert.ok(!itemIds(m.items).includes('github'), `menu ${m.id} has no GitHub link`);
|
|
151
|
+
}
|
|
152
|
+
});
|
package/src/chrome/MenuBar.tsx
CHANGED
|
@@ -54,52 +54,24 @@ import type { CasualSheetsAPI } from '../sheets/api';
|
|
|
54
54
|
import { ensurePluginByName } from '../univer';
|
|
55
55
|
import { Icon } from './Icon';
|
|
56
56
|
import { ensureChromeFonts } from './fonts';
|
|
57
|
-
import { useDialogs
|
|
58
|
-
import type { ChromeExtensions
|
|
59
|
-
|
|
60
|
-
|
|
57
|
+
import { useDialogs } from './dialog-context';
|
|
58
|
+
import type { ChromeExtensions } from './extensions';
|
|
59
|
+
import {
|
|
60
|
+
computeVisibleMenus,
|
|
61
|
+
type MenuDef,
|
|
62
|
+
type MenuId,
|
|
63
|
+
type MenuItemDef,
|
|
64
|
+
type RunFn,
|
|
65
|
+
} from './menu-model';
|
|
66
|
+
|
|
67
|
+
export type { MenuDialogKind } from './menu-model';
|
|
61
68
|
|
|
62
69
|
/**
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
70
|
+
* The menu data model + pure gating engine live in `./menu-model` (univer-free,
|
|
71
|
+
* so unit-testable). `RunFn` / `MenuItemDef` / `MenuDef` / `MenuId` /
|
|
72
|
+
* `MenuDialogKind` are re-imported above; the `run` handlers that populate
|
|
73
|
+
* `MENUS` are defined inline below against the `@univerjs/core` facade.
|
|
67
74
|
*/
|
|
68
|
-
export type MenuDialogKind = DialogKind;
|
|
69
|
-
|
|
70
|
-
type RunFn = (api: CasualSheetsAPI) => void;
|
|
71
|
-
|
|
72
|
-
type MenuItemDef =
|
|
73
|
-
| {
|
|
74
|
-
kind: 'item';
|
|
75
|
-
id: string;
|
|
76
|
-
label: string;
|
|
77
|
-
icon?: string;
|
|
78
|
-
shortcut?: string;
|
|
79
|
-
/** Dispatch a command / facade call directly. */
|
|
80
|
-
run?: RunFn;
|
|
81
|
-
/** Route through the host's `onDialogRequest`. Omitted if no host hook. */
|
|
82
|
-
dialog?: MenuDialogKind;
|
|
83
|
-
/** Feature gate — item hidden when `features[feature] === false`. */
|
|
84
|
-
feature?: string;
|
|
85
|
-
}
|
|
86
|
-
| { kind: 'separator'; id: string; feature?: string }
|
|
87
|
-
| {
|
|
88
|
-
kind: 'submenu';
|
|
89
|
-
id: string;
|
|
90
|
-
label: string;
|
|
91
|
-
icon?: string;
|
|
92
|
-
items: MenuItemDef[];
|
|
93
|
-
feature?: string;
|
|
94
|
-
};
|
|
95
|
-
|
|
96
|
-
interface MenuDef {
|
|
97
|
-
id: MenuId;
|
|
98
|
-
label: string;
|
|
99
|
-
/** Feature gate for the whole menu. */
|
|
100
|
-
feature?: string;
|
|
101
|
-
items: MenuItemDef[];
|
|
102
|
-
}
|
|
103
75
|
|
|
104
76
|
/**
|
|
105
77
|
* Pretty-print a `Ctrl+Shift+X` shortcut for the current platform. The SDK
|
|
@@ -559,6 +531,7 @@ const MENUS: MenuDef[] = [
|
|
|
559
531
|
label: 'About casual sheets',
|
|
560
532
|
icon: 'help_outline',
|
|
561
533
|
dialog: 'about',
|
|
534
|
+
feature: 'branding',
|
|
562
535
|
},
|
|
563
536
|
],
|
|
564
537
|
},
|
|
@@ -1106,6 +1079,12 @@ const MENUS: MenuDef[] = [
|
|
|
1106
1079
|
{
|
|
1107
1080
|
id: 'help',
|
|
1108
1081
|
label: 'Help',
|
|
1082
|
+
// Whole-menu gate: an embedded host passes `features={{ help: false }}` to
|
|
1083
|
+
// drop the Help menu entirely (no editor-branded surface). Individual
|
|
1084
|
+
// branding links below also carry `feature: 'branding'` so a host that
|
|
1085
|
+
// keeps Help (for keyboard shortcuts) can still hide the About / GitHub
|
|
1086
|
+
// links with `features={{ branding: false }}`.
|
|
1087
|
+
feature: 'help',
|
|
1109
1088
|
items: [
|
|
1110
1089
|
{
|
|
1111
1090
|
kind: 'item',
|
|
@@ -1115,14 +1094,22 @@ const MENUS: MenuDef[] = [
|
|
|
1115
1094
|
shortcut: 'Ctrl+/',
|
|
1116
1095
|
dialog: 'keyboard-shortcuts',
|
|
1117
1096
|
},
|
|
1118
|
-
{ kind: 'separator', id: 'sep-help' },
|
|
1119
|
-
{
|
|
1097
|
+
{ kind: 'separator', id: 'sep-help', feature: 'branding' },
|
|
1098
|
+
{
|
|
1099
|
+
kind: 'item',
|
|
1100
|
+
id: 'about',
|
|
1101
|
+
label: 'About casual sheets',
|
|
1102
|
+
icon: 'info',
|
|
1103
|
+
dialog: 'about',
|
|
1104
|
+
feature: 'branding',
|
|
1105
|
+
},
|
|
1120
1106
|
{
|
|
1121
1107
|
kind: 'item',
|
|
1122
1108
|
id: 'github',
|
|
1123
1109
|
label: 'View on GitHub',
|
|
1124
1110
|
icon: 'open_in_new',
|
|
1125
1111
|
run: () => openExternal('https://github.com/CasualOffice/sheets'),
|
|
1112
|
+
feature: 'branding',
|
|
1126
1113
|
},
|
|
1127
1114
|
],
|
|
1128
1115
|
},
|
|
@@ -1219,93 +1206,6 @@ const SUBMENU_PANEL_STYLE: CSSProperties = {
|
|
|
1219
1206
|
zIndex: 1001,
|
|
1220
1207
|
};
|
|
1221
1208
|
|
|
1222
|
-
/* ───────────────────────────── filtering ──────────────────────────────── */
|
|
1223
|
-
|
|
1224
|
-
/** True when the feature gate (if any) is enabled (default: enabled). */
|
|
1225
|
-
function featureOn(feature: string | undefined, features: Record<string, boolean>): boolean {
|
|
1226
|
-
if (!feature) return true;
|
|
1227
|
-
return features[feature] !== false;
|
|
1228
|
-
}
|
|
1229
|
-
|
|
1230
|
-
/**
|
|
1231
|
-
* Keep an item if its feature is on AND — for a dialog item — the chrome can
|
|
1232
|
-
* open it (built-in dialog, host override, or `onDialogRequest`). Dialog items
|
|
1233
|
-
* with no way to open are dropped (the SDK never fakes a dialog). Submenus are
|
|
1234
|
-
* filtered recursively and dropped when empty.
|
|
1235
|
-
*/
|
|
1236
|
-
function keepItem(
|
|
1237
|
-
item: MenuItemDef,
|
|
1238
|
-
features: Record<string, boolean>,
|
|
1239
|
-
canOpen: (kind: DialogKind) => boolean,
|
|
1240
|
-
): MenuItemDef | null {
|
|
1241
|
-
if (!featureOn(item.feature, features)) return null;
|
|
1242
|
-
if (item.kind === 'separator') return item;
|
|
1243
|
-
if (item.kind === 'submenu') {
|
|
1244
|
-
const items = filterItems(item.items, features, canOpen);
|
|
1245
|
-
if (items.length === 0) return null;
|
|
1246
|
-
return { ...item, items };
|
|
1247
|
-
}
|
|
1248
|
-
if (item.dialog && !canOpen(item.dialog)) return null;
|
|
1249
|
-
return item;
|
|
1250
|
-
}
|
|
1251
|
-
|
|
1252
|
-
/** Filter a list and collapse leading/trailing/double separators. */
|
|
1253
|
-
function filterItems(
|
|
1254
|
-
items: MenuItemDef[],
|
|
1255
|
-
features: Record<string, boolean>,
|
|
1256
|
-
canOpen: (kind: DialogKind) => boolean,
|
|
1257
|
-
): MenuItemDef[] {
|
|
1258
|
-
const kept = items
|
|
1259
|
-
.map((i) => keepItem(i, features, canOpen))
|
|
1260
|
-
.filter((i): i is MenuItemDef => i !== null);
|
|
1261
|
-
// Collapse separators: drop leading, trailing, and runs.
|
|
1262
|
-
const out: MenuItemDef[] = [];
|
|
1263
|
-
for (const item of kept) {
|
|
1264
|
-
if (item.kind === 'separator') {
|
|
1265
|
-
if (out.length === 0) continue;
|
|
1266
|
-
if (out[out.length - 1].kind === 'separator') continue;
|
|
1267
|
-
}
|
|
1268
|
-
out.push(item);
|
|
1269
|
-
}
|
|
1270
|
-
while (out.length > 0 && out[out.length - 1].kind === 'separator') out.pop();
|
|
1271
|
-
return out;
|
|
1272
|
-
}
|
|
1273
|
-
|
|
1274
|
-
/* ─────────────────────────── host extensions ──────────────────────────── */
|
|
1275
|
-
|
|
1276
|
-
/**
|
|
1277
|
-
* Append host menu extensions to their target top-level menu. Each extension
|
|
1278
|
-
* becomes a normal `item` (with a leading separator before the first host item
|
|
1279
|
-
* in that menu so it's visually grouped). Host items dispatch via `onClick` or
|
|
1280
|
-
* route a `dialog` kind through the dialog host, exactly like built-ins.
|
|
1281
|
-
*/
|
|
1282
|
-
function withMenuExtensions(menus: MenuDef[], ext?: MenuExtension[]): MenuDef[] {
|
|
1283
|
-
if (!ext || ext.length === 0) return menus;
|
|
1284
|
-
const byMenu = new Map<MenuId, MenuExtension[]>();
|
|
1285
|
-
for (const e of ext) {
|
|
1286
|
-
const list = byMenu.get(e.menu) ?? [];
|
|
1287
|
-
list.push(e);
|
|
1288
|
-
byMenu.set(e.menu, list);
|
|
1289
|
-
}
|
|
1290
|
-
return menus.map((menu) => {
|
|
1291
|
-
const extras = byMenu.get(menu.id);
|
|
1292
|
-
if (!extras || extras.length === 0) return menu;
|
|
1293
|
-
const items: MenuItemDef[] = [...menu.items, { kind: 'separator', id: `ext-sep-${menu.id}` }];
|
|
1294
|
-
for (const e of extras) {
|
|
1295
|
-
items.push({
|
|
1296
|
-
kind: 'item',
|
|
1297
|
-
id: `ext-${e.id}`,
|
|
1298
|
-
label: e.label,
|
|
1299
|
-
icon: e.icon,
|
|
1300
|
-
shortcut: e.shortcut,
|
|
1301
|
-
dialog: e.dialog,
|
|
1302
|
-
run: e.onClick ? (api) => e.onClick?.(api) : undefined,
|
|
1303
|
-
});
|
|
1304
|
-
}
|
|
1305
|
-
return { ...menu, items };
|
|
1306
|
-
});
|
|
1307
|
-
}
|
|
1308
|
-
|
|
1309
1209
|
/* ───────────────────────────── component ──────────────────────────────── */
|
|
1310
1210
|
|
|
1311
1211
|
export interface MenuBarProps {
|
|
@@ -1381,15 +1281,10 @@ export function MenuBar({ api, features = {}, extensions }: MenuBarProps) {
|
|
|
1381
1281
|
item.run?.(api);
|
|
1382
1282
|
};
|
|
1383
1283
|
|
|
1384
|
-
// Compute the visible menus once per render (feature + dialog-open gating
|
|
1385
|
-
//
|
|
1386
|
-
|
|
1387
|
-
const visibleMenus =
|
|
1388
|
-
.map((menu) => ({
|
|
1389
|
-
...menu,
|
|
1390
|
-
items: filterItems(menu.items, features, dialogs.canOpen),
|
|
1391
|
-
}))
|
|
1392
|
-
.filter((menu) => featureOn(menu.feature, features) && menu.items.length > 0);
|
|
1284
|
+
// Compute the visible menus once per render (feature + dialog-open gating +
|
|
1285
|
+
// host menu extensions). Extracted to `computeVisibleMenus` so the gating
|
|
1286
|
+
// contract is unit-testable without a DOM.
|
|
1287
|
+
const visibleMenus = computeVisibleMenus(MENUS, features, dialogs.canOpen, extensions?.menu);
|
|
1393
1288
|
|
|
1394
1289
|
const renderItems = (items: MenuItemDef[]): ReactNode =>
|
|
1395
1290
|
items.map((item) => {
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright 2026 Casual Office
|
|
3
|
+
*
|
|
4
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
* you may not use this file except in compliance with the License.
|
|
6
|
+
* You may obtain a copy of the License at
|
|
7
|
+
*
|
|
8
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
*
|
|
10
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
* See the License for the specific language governing permissions and
|
|
14
|
+
* limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* MenuBar data model + pure gating engine.
|
|
19
|
+
*
|
|
20
|
+
* Split out of `MenuBar.tsx` so the feature-gating contract is unit-testable
|
|
21
|
+
* under `node --test`: `MenuBar.tsx` statically imports `@univerjs/core` values
|
|
22
|
+
* (which the vendored typeless-package ESM can't expose as named exports to
|
|
23
|
+
* node), so it can't be imported in a DOM-less test. This module has only
|
|
24
|
+
* type-only imports (all erased at build), so `computeVisibleMenus` — the code
|
|
25
|
+
* that decides which menus/items a host actually sees — can be exercised
|
|
26
|
+
* directly.
|
|
27
|
+
*
|
|
28
|
+
* Feature gates: pass `features` to hide a control or whole menu group when its
|
|
29
|
+
* feature is disabled. Defaults to all-enabled. A control whose feature is
|
|
30
|
+
* `false` does not render. An entire top-level menu whose own `feature` is
|
|
31
|
+
* `false`, or that ends up with no runnable items, is dropped.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import type { DialogKind } from './dialog-context';
|
|
35
|
+
import type { CasualSheetsAPI } from '../sheets/api';
|
|
36
|
+
import type { MenuExtension } from './extensions';
|
|
37
|
+
|
|
38
|
+
export type MenuId = 'file' | 'edit' | 'view' | 'insert' | 'format' | 'data' | 'help';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Dialog kinds the host can choose to render via `onDialogRequest`. These are
|
|
42
|
+
* the actions the SDK chrome can't fulfil on its own (no built-in modal). The
|
|
43
|
+
* string is passed straight to the host hook; the `context` (when present)
|
|
44
|
+
* carries the pre-resolved A1 selection so the host doesn't have to re-read it.
|
|
45
|
+
*/
|
|
46
|
+
export type MenuDialogKind = DialogKind;
|
|
47
|
+
|
|
48
|
+
export type RunFn = (api: CasualSheetsAPI) => void;
|
|
49
|
+
|
|
50
|
+
export type MenuItemDef =
|
|
51
|
+
| {
|
|
52
|
+
kind: 'item';
|
|
53
|
+
id: string;
|
|
54
|
+
label: string;
|
|
55
|
+
icon?: string;
|
|
56
|
+
shortcut?: string;
|
|
57
|
+
/** Dispatch a command / facade call directly. */
|
|
58
|
+
run?: RunFn;
|
|
59
|
+
/** Route through the host's `onDialogRequest`. Omitted if no host hook. */
|
|
60
|
+
dialog?: MenuDialogKind;
|
|
61
|
+
/** Feature gate — item hidden when `features[feature] === false`. */
|
|
62
|
+
feature?: string;
|
|
63
|
+
}
|
|
64
|
+
| { kind: 'separator'; id: string; feature?: string }
|
|
65
|
+
| {
|
|
66
|
+
kind: 'submenu';
|
|
67
|
+
id: string;
|
|
68
|
+
label: string;
|
|
69
|
+
icon?: string;
|
|
70
|
+
items: MenuItemDef[];
|
|
71
|
+
feature?: string;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export interface MenuDef {
|
|
75
|
+
id: MenuId;
|
|
76
|
+
label: string;
|
|
77
|
+
/** Feature gate for the whole menu. */
|
|
78
|
+
feature?: string;
|
|
79
|
+
items: MenuItemDef[];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/* ───────────────────────────── filtering ──────────────────────────────── */
|
|
83
|
+
|
|
84
|
+
/** True when the feature gate (if any) is enabled (default: enabled). */
|
|
85
|
+
export function featureOn(feature: string | undefined, features: Record<string, boolean>): boolean {
|
|
86
|
+
if (!feature) return true;
|
|
87
|
+
return features[feature] !== false;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Keep an item if its feature is on AND — for a dialog item — the chrome can
|
|
92
|
+
* open it (built-in dialog, host override, or `onDialogRequest`). Dialog items
|
|
93
|
+
* with no way to open are dropped (the SDK never fakes a dialog). Submenus are
|
|
94
|
+
* filtered recursively and dropped when empty.
|
|
95
|
+
*/
|
|
96
|
+
export function keepItem(
|
|
97
|
+
item: MenuItemDef,
|
|
98
|
+
features: Record<string, boolean>,
|
|
99
|
+
canOpen: (kind: DialogKind) => boolean,
|
|
100
|
+
): MenuItemDef | null {
|
|
101
|
+
if (!featureOn(item.feature, features)) return null;
|
|
102
|
+
if (item.kind === 'separator') return item;
|
|
103
|
+
if (item.kind === 'submenu') {
|
|
104
|
+
const items = filterItems(item.items, features, canOpen);
|
|
105
|
+
if (items.length === 0) return null;
|
|
106
|
+
return { ...item, items };
|
|
107
|
+
}
|
|
108
|
+
if (item.dialog && !canOpen(item.dialog)) return null;
|
|
109
|
+
return item;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Filter a list and collapse leading/trailing/double separators. */
|
|
113
|
+
export function filterItems(
|
|
114
|
+
items: MenuItemDef[],
|
|
115
|
+
features: Record<string, boolean>,
|
|
116
|
+
canOpen: (kind: DialogKind) => boolean,
|
|
117
|
+
): MenuItemDef[] {
|
|
118
|
+
const kept = items
|
|
119
|
+
.map((i) => keepItem(i, features, canOpen))
|
|
120
|
+
.filter((i): i is MenuItemDef => i !== null);
|
|
121
|
+
// Collapse separators: drop leading, trailing, and runs.
|
|
122
|
+
const out: MenuItemDef[] = [];
|
|
123
|
+
for (const item of kept) {
|
|
124
|
+
if (item.kind === 'separator') {
|
|
125
|
+
if (out.length === 0) continue;
|
|
126
|
+
if (out[out.length - 1].kind === 'separator') continue;
|
|
127
|
+
}
|
|
128
|
+
out.push(item);
|
|
129
|
+
}
|
|
130
|
+
while (out.length > 0 && out[out.length - 1].kind === 'separator') out.pop();
|
|
131
|
+
return out;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/* ─────────────────────────── host extensions ──────────────────────────── */
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Append host menu extensions to their target top-level menu. Each extension
|
|
138
|
+
* becomes a normal `item` (with a leading separator before the first host item
|
|
139
|
+
* in that menu so it's visually grouped). Host items dispatch via `onClick` or
|
|
140
|
+
* route a `dialog` kind through the dialog host, exactly like built-ins.
|
|
141
|
+
*/
|
|
142
|
+
export function withMenuExtensions(menus: MenuDef[], ext?: MenuExtension[]): MenuDef[] {
|
|
143
|
+
if (!ext || ext.length === 0) return menus;
|
|
144
|
+
const byMenu = new Map<MenuId, MenuExtension[]>();
|
|
145
|
+
for (const e of ext) {
|
|
146
|
+
const list = byMenu.get(e.menu) ?? [];
|
|
147
|
+
list.push(e);
|
|
148
|
+
byMenu.set(e.menu, list);
|
|
149
|
+
}
|
|
150
|
+
return menus.map((menu) => {
|
|
151
|
+
const extras = byMenu.get(menu.id);
|
|
152
|
+
if (!extras || extras.length === 0) return menu;
|
|
153
|
+
const items: MenuItemDef[] = [...menu.items, { kind: 'separator', id: `ext-sep-${menu.id}` }];
|
|
154
|
+
for (const e of extras) {
|
|
155
|
+
items.push({
|
|
156
|
+
kind: 'item',
|
|
157
|
+
id: `ext-${e.id}`,
|
|
158
|
+
label: e.label,
|
|
159
|
+
icon: e.icon,
|
|
160
|
+
shortcut: e.shortcut,
|
|
161
|
+
dialog: e.dialog,
|
|
162
|
+
run: e.onClick ? (api) => e.onClick?.(api) : undefined,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
return { ...menu, items };
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Resolve the menus a host actually sees: append menu extensions, filter every
|
|
171
|
+
* item by its `feature` gate + dialog-openability, then drop any top-level menu
|
|
172
|
+
* whose own `feature` is off or that ends up empty.
|
|
173
|
+
*
|
|
174
|
+
* Exported (not inlined in the component) so the feature-gating contract — e.g.
|
|
175
|
+
* `features={{ help: false }}` drops the Help menu and
|
|
176
|
+
* `features={{ branding: false }}` drops the "View on GitHub" / "About" links —
|
|
177
|
+
* is unit-testable without a DOM.
|
|
178
|
+
*/
|
|
179
|
+
export function computeVisibleMenus(
|
|
180
|
+
menus: MenuDef[],
|
|
181
|
+
features: Record<string, boolean>,
|
|
182
|
+
canOpen: (kind: DialogKind) => boolean,
|
|
183
|
+
ext?: MenuExtension[],
|
|
184
|
+
): MenuDef[] {
|
|
185
|
+
return withMenuExtensions(menus, ext)
|
|
186
|
+
.map((menu) => ({
|
|
187
|
+
...menu,
|
|
188
|
+
items: filterItems(menu.items, features, canOpen),
|
|
189
|
+
}))
|
|
190
|
+
.filter((menu) => featureOn(menu.feature, features) && menu.items.length > 0);
|
|
191
|
+
}
|