@casualoffice/sheets 0.16.0 → 0.18.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 +3421 -307
- 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 +3380 -266
- package/dist/chrome.js.map +1 -1
- package/dist/embed/embed-runtime.js +152 -150
- package/package.json +1 -1
- package/src/chrome/ConditionalFormattingDialog.tsx +534 -0
- package/src/chrome/CustomSortDialog.tsx +357 -0
- package/src/chrome/DataValidationDialog.tsx +536 -0
- package/src/chrome/DeleteCellsDialog.tsx +183 -0
- package/src/chrome/GoalSeekDialog.tsx +370 -0
- package/src/chrome/InsertCellsDialog.tsx +185 -0
- package/src/chrome/InsertChartDialog.tsx +490 -0
- package/src/chrome/InsertFunctionDialog.tsx +493 -0
- package/src/chrome/InsertPivotDialog.tsx +488 -0
- package/src/chrome/InsertSparklineDialog.tsx +344 -0
- package/src/chrome/MenuBar.feature-gate.unit.test.ts +152 -0
- package/src/chrome/MenuBar.tsx +36 -141
- package/src/chrome/NameManagerDialog.tsx +378 -0
- package/src/chrome/PasteSpecialDialog.tsx +286 -0
- package/src/chrome/dialog-context.tsx +24 -0
- package/src/chrome/menu-model.ts +191 -0
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,378 @@
|
|
|
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
|
+
* NameManagerDialog — the SDK chrome's built-in Name Manager (defined names).
|
|
19
|
+
*
|
|
20
|
+
* Mirrors the Data Validation / Format Cells exemplars: it reaches the workbook
|
|
21
|
+
* off the FUniver facade and drives the workbook's defined-name API to list,
|
|
22
|
+
* add, edit, and delete named ranges. Grounded in
|
|
23
|
+
* `@univerjs/sheets/lib/types/facade/f-workbook.d.ts`:
|
|
24
|
+
* - `getDefinedNames(): FDefinedName[]` (L571)
|
|
25
|
+
* - `getDefinedName(name): FDefinedName | null` (L559)
|
|
26
|
+
* - `insertDefinedName(name, formulaOrRefString): FWorkbook` (L639)
|
|
27
|
+
* - `deleteDefinedName(name): boolean` (L651)
|
|
28
|
+
* - `updateDefinedNameBuilder(param): void` (L626)
|
|
29
|
+
* and `f-defined-name.d.ts`: `FDefinedName.getName()` (L221) /
|
|
30
|
+
* `getFormulaOrRefString()` (L265) / `toBuilder()` (L385); the builder's
|
|
31
|
+
* `setName` (L44) / `setRef` (L74) / `build()` (L175). The "add from selection"
|
|
32
|
+
* ref comes from `FRange.getA1Notation(true)` (sheet-qualified, e.g.
|
|
33
|
+
* `Sheet1!A1:B2` — `f-range.d.ts` L1220), which is exactly the shape
|
|
34
|
+
* `insertDefinedName`'s `formulaOrRefString` accepts.
|
|
35
|
+
*
|
|
36
|
+
* Univer's defined-name API is unscoped by name (no id needed here): to "edit" a
|
|
37
|
+
* name we rebuild it from `getDefinedName(oldName).toBuilder()`, preserving its
|
|
38
|
+
* identity while updating name + ref, then `updateDefinedNameBuilder`.
|
|
39
|
+
*
|
|
40
|
+
* Mounted by `<DialogHost>` when `openDialog('name-manager')` is called and no
|
|
41
|
+
* host override is registered.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import { useCallback, useMemo, useState, type CSSProperties } from 'react';
|
|
45
|
+
import type { DialogComponentProps } from './extensions';
|
|
46
|
+
import type { CasualSheetsAPI } from '../sheets/api';
|
|
47
|
+
import { Dialog } from './Dialog';
|
|
48
|
+
import {
|
|
49
|
+
DIALOG_BTN_PRIMARY_STYLE,
|
|
50
|
+
DIALOG_BTN_SECONDARY_STYLE,
|
|
51
|
+
DIALOG_FIELD_STYLE,
|
|
52
|
+
DIALOG_INPUT_STYLE,
|
|
53
|
+
DIALOG_LABEL_STYLE,
|
|
54
|
+
} from './dialog-styles';
|
|
55
|
+
|
|
56
|
+
/** Minimal shape of the FDefinedNameBuilder chain we use (grounded in f-defined-name.ts). */
|
|
57
|
+
interface FDefinedNameBuilderLike {
|
|
58
|
+
setName(name: string): FDefinedNameBuilderLike;
|
|
59
|
+
setRef(a1Notation: string): FDefinedNameBuilderLike;
|
|
60
|
+
build(): unknown;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Minimal shape of an FDefinedName we read (grounded in f-defined-name.ts). */
|
|
64
|
+
interface FDefinedNameLike {
|
|
65
|
+
getName(): string;
|
|
66
|
+
getFormulaOrRefString(): string;
|
|
67
|
+
toBuilder(): FDefinedNameBuilderLike;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Minimal shape of the FWorkbook defined-name surface (grounded in f-workbook.d.ts). */
|
|
71
|
+
interface FWorkbookLike {
|
|
72
|
+
getDefinedNames(): FDefinedNameLike[];
|
|
73
|
+
getDefinedName(name: string): FDefinedNameLike | null;
|
|
74
|
+
insertDefinedName(name: string, formulaOrRefString: string): unknown;
|
|
75
|
+
deleteDefinedName(name: string): boolean;
|
|
76
|
+
updateDefinedNameBuilder(param: unknown): void;
|
|
77
|
+
getActiveSheet(): {
|
|
78
|
+
getActiveRange(): { getA1Notation(withSheet?: boolean): string } | null;
|
|
79
|
+
} | null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
interface NameRow {
|
|
83
|
+
name: string;
|
|
84
|
+
refersTo: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Names must start with a letter/underscore and avoid A1-like tokens; keep it
|
|
88
|
+
* loose but block the common mistakes (spaces, leading digit, empty). */
|
|
89
|
+
const NAME_RE = /^[A-Za-z_][A-Za-z0-9_.]*$/;
|
|
90
|
+
|
|
91
|
+
function workbook(api: CasualSheetsAPI): FWorkbookLike | null {
|
|
92
|
+
return (api.univer.getActiveWorkbook() as unknown as FWorkbookLike | null) ?? null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function readRows(api: CasualSheetsAPI): NameRow[] {
|
|
96
|
+
const wb = workbook(api);
|
|
97
|
+
if (!wb) return [];
|
|
98
|
+
return wb.getDefinedNames().map((dn) => ({
|
|
99
|
+
name: dn.getName(),
|
|
100
|
+
refersTo: dn.getFormulaOrRefString(),
|
|
101
|
+
}));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Sheet-qualified A1 of the current selection, e.g. `Sheet1!A1:B2`, or ''. */
|
|
105
|
+
function selectionRef(api: CasualSheetsAPI): string {
|
|
106
|
+
const range = workbook(api)?.getActiveSheet()?.getActiveRange();
|
|
107
|
+
return range?.getA1Notation(true) ?? '';
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const ROW_STYLE: CSSProperties = {
|
|
111
|
+
display: 'grid',
|
|
112
|
+
gridTemplateColumns: '1fr 1.3fr auto',
|
|
113
|
+
alignItems: 'center',
|
|
114
|
+
gap: 8,
|
|
115
|
+
padding: '6px 8px',
|
|
116
|
+
borderBottom: '1px solid var(--cs-chrome-border, #edeff3)',
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const LIST_STYLE: CSSProperties = {
|
|
120
|
+
border: '1px solid var(--cs-chrome-border, #cdd3db)',
|
|
121
|
+
borderRadius: 6,
|
|
122
|
+
maxHeight: 200,
|
|
123
|
+
overflow: 'auto',
|
|
124
|
+
marginBottom: 16,
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const EMPTY_STYLE: CSSProperties = {
|
|
128
|
+
padding: '16px 8px',
|
|
129
|
+
fontSize: 13,
|
|
130
|
+
color: 'var(--cs-chrome-muted, #605e5c)',
|
|
131
|
+
textAlign: 'center',
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const REF_CELL_STYLE: CSSProperties = {
|
|
135
|
+
fontSize: 12,
|
|
136
|
+
color: 'var(--cs-chrome-muted, #605e5c)',
|
|
137
|
+
overflow: 'hidden',
|
|
138
|
+
textOverflow: 'ellipsis',
|
|
139
|
+
whiteSpace: 'nowrap',
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const ROW_ACTIONS_STYLE: CSSProperties = {
|
|
143
|
+
display: 'flex',
|
|
144
|
+
gap: 4,
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const SMALL_BTN_STYLE: CSSProperties = {
|
|
148
|
+
...DIALOG_BTN_SECONDARY_STYLE,
|
|
149
|
+
height: 24,
|
|
150
|
+
padding: '0 8px',
|
|
151
|
+
fontSize: 12,
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const SECTION_TITLE_STYLE: CSSProperties = {
|
|
155
|
+
fontSize: 13,
|
|
156
|
+
fontWeight: 600,
|
|
157
|
+
color: 'var(--cs-chrome-fg, #201f1e)',
|
|
158
|
+
margin: '0 0 8px',
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const ERROR_STYLE: CSSProperties = {
|
|
162
|
+
fontSize: 12,
|
|
163
|
+
color: 'var(--cs-chrome-danger, #b91c1c)',
|
|
164
|
+
marginBottom: 8,
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const TWO_COL_STYLE: CSSProperties = {
|
|
168
|
+
display: 'grid',
|
|
169
|
+
gridTemplateColumns: '1fr 1.3fr',
|
|
170
|
+
gap: 8,
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
export function NameManagerDialog({ api, onClose }: DialogComponentProps) {
|
|
174
|
+
const [rows, setRows] = useState<NameRow[]>(() => readRows(api));
|
|
175
|
+
// When editing, this holds the ORIGINAL name of the row being edited (used to
|
|
176
|
+
// locate + update it). null means we're adding a new name.
|
|
177
|
+
const [editingOriginal, setEditingOriginal] = useState<string | null>(null);
|
|
178
|
+
const [nameInput, setNameInput] = useState('');
|
|
179
|
+
const [refInput, setRefInput] = useState('');
|
|
180
|
+
const [error, setError] = useState<string | null>(null);
|
|
181
|
+
|
|
182
|
+
const initialSelRef = useMemo(() => selectionRef(api), [api]);
|
|
183
|
+
|
|
184
|
+
const refresh = useCallback(() => setRows(readRows(api)), [api]);
|
|
185
|
+
|
|
186
|
+
const resetForm = useCallback(() => {
|
|
187
|
+
setEditingOriginal(null);
|
|
188
|
+
setNameInput('');
|
|
189
|
+
setRefInput('');
|
|
190
|
+
setError(null);
|
|
191
|
+
}, []);
|
|
192
|
+
|
|
193
|
+
const startEdit = (row: NameRow) => {
|
|
194
|
+
setEditingOriginal(row.name);
|
|
195
|
+
setNameInput(row.name);
|
|
196
|
+
setRefInput(row.refersTo);
|
|
197
|
+
setError(null);
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const useSelection = () => {
|
|
201
|
+
const ref = selectionRef(api);
|
|
202
|
+
if (ref) setRefInput(ref);
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const remove = (row: NameRow) => {
|
|
206
|
+
const wb = workbook(api);
|
|
207
|
+
if (!wb) return;
|
|
208
|
+
wb.deleteDefinedName(row.name);
|
|
209
|
+
if (editingOriginal === row.name) resetForm();
|
|
210
|
+
refresh();
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const submit = () => {
|
|
214
|
+
const wb = workbook(api);
|
|
215
|
+
if (!wb) {
|
|
216
|
+
setError('No active workbook.');
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
const name = nameInput.trim();
|
|
220
|
+
const ref = refInput.trim();
|
|
221
|
+
if (!name) {
|
|
222
|
+
setError('Enter a name.');
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
if (!NAME_RE.test(name)) {
|
|
226
|
+
setError('Names must start with a letter or underscore and contain no spaces.');
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
if (!ref) {
|
|
230
|
+
setError('Enter a range or formula (e.g. Sheet1!A1:B2).');
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Duplicate-name guard: allow keeping the same name while editing, but block
|
|
235
|
+
// colliding with a different existing name.
|
|
236
|
+
const collides = rows.some(
|
|
237
|
+
(r) => r.name.toLowerCase() === name.toLowerCase() && r.name !== editingOriginal,
|
|
238
|
+
);
|
|
239
|
+
if (collides) {
|
|
240
|
+
setError(`A defined name "${name}" already exists.`);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (editingOriginal === null) {
|
|
245
|
+
// Add.
|
|
246
|
+
wb.insertDefinedName(name, ref);
|
|
247
|
+
} else {
|
|
248
|
+
// Edit — rebuild from the existing name to preserve identity/scope, then
|
|
249
|
+
// update. If the row vanished (e.g. deleted elsewhere) fall back to insert.
|
|
250
|
+
const existing = wb.getDefinedName(editingOriginal);
|
|
251
|
+
if (existing) {
|
|
252
|
+
const param = existing.toBuilder().setName(name).setRef(ref).build();
|
|
253
|
+
wb.updateDefinedNameBuilder(param);
|
|
254
|
+
} else {
|
|
255
|
+
wb.insertDefinedName(name, ref);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
refresh();
|
|
260
|
+
resetForm();
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
const isEditing = editingOriginal !== null;
|
|
264
|
+
|
|
265
|
+
return (
|
|
266
|
+
<Dialog
|
|
267
|
+
title="Name manager"
|
|
268
|
+
onClose={onClose}
|
|
269
|
+
width={480}
|
|
270
|
+
data-testid="cs-name-manager-dialog"
|
|
271
|
+
footer={
|
|
272
|
+
<>
|
|
273
|
+
<span style={{ flex: 1 }} />
|
|
274
|
+
<button type="button" style={DIALOG_BTN_SECONDARY_STYLE} onClick={onClose}>
|
|
275
|
+
Close
|
|
276
|
+
</button>
|
|
277
|
+
</>
|
|
278
|
+
}
|
|
279
|
+
>
|
|
280
|
+
<h3 style={SECTION_TITLE_STYLE}>Defined names</h3>
|
|
281
|
+
<div style={LIST_STYLE} data-testid="cs-name-manager-list">
|
|
282
|
+
{rows.length === 0 ? (
|
|
283
|
+
<div style={EMPTY_STYLE} data-testid="cs-name-manager-empty">
|
|
284
|
+
No defined names yet. Add one below.
|
|
285
|
+
</div>
|
|
286
|
+
) : (
|
|
287
|
+
rows.map((row) => (
|
|
288
|
+
<div key={row.name} style={ROW_STYLE} data-testid="cs-name-manager-row">
|
|
289
|
+
<span style={{ fontWeight: 500 }}>{row.name}</span>
|
|
290
|
+
<span style={REF_CELL_STYLE} title={row.refersTo}>
|
|
291
|
+
{row.refersTo}
|
|
292
|
+
</span>
|
|
293
|
+
<span style={ROW_ACTIONS_STYLE}>
|
|
294
|
+
<button
|
|
295
|
+
type="button"
|
|
296
|
+
style={SMALL_BTN_STYLE}
|
|
297
|
+
data-testid="cs-name-manager-edit"
|
|
298
|
+
onClick={() => startEdit(row)}
|
|
299
|
+
>
|
|
300
|
+
Edit
|
|
301
|
+
</button>
|
|
302
|
+
<button
|
|
303
|
+
type="button"
|
|
304
|
+
style={SMALL_BTN_STYLE}
|
|
305
|
+
data-testid="cs-name-manager-delete"
|
|
306
|
+
onClick={() => remove(row)}
|
|
307
|
+
>
|
|
308
|
+
Delete
|
|
309
|
+
</button>
|
|
310
|
+
</span>
|
|
311
|
+
</div>
|
|
312
|
+
))
|
|
313
|
+
)}
|
|
314
|
+
</div>
|
|
315
|
+
|
|
316
|
+
<h3 style={SECTION_TITLE_STYLE}>{isEditing ? 'Edit name' : 'Add a name'}</h3>
|
|
317
|
+
|
|
318
|
+
{error && (
|
|
319
|
+
<div style={ERROR_STYLE} data-testid="cs-name-manager-error">
|
|
320
|
+
{error}
|
|
321
|
+
</div>
|
|
322
|
+
)}
|
|
323
|
+
|
|
324
|
+
<div style={TWO_COL_STYLE}>
|
|
325
|
+
<label style={DIALOG_FIELD_STYLE}>
|
|
326
|
+
<span style={DIALOG_LABEL_STYLE}>Name</span>
|
|
327
|
+
<input
|
|
328
|
+
style={DIALOG_INPUT_STYLE}
|
|
329
|
+
data-testid="cs-name-manager-name"
|
|
330
|
+
value={nameInput}
|
|
331
|
+
placeholder="MyRange"
|
|
332
|
+
onChange={(e) => setNameInput(e.target.value)}
|
|
333
|
+
/>
|
|
334
|
+
</label>
|
|
335
|
+
<label style={DIALOG_FIELD_STYLE}>
|
|
336
|
+
<span style={DIALOG_LABEL_STYLE}>Refers to</span>
|
|
337
|
+
<input
|
|
338
|
+
style={DIALOG_INPUT_STYLE}
|
|
339
|
+
data-testid="cs-name-manager-ref"
|
|
340
|
+
value={refInput}
|
|
341
|
+
placeholder={initialSelRef || 'Sheet1!A1:B2'}
|
|
342
|
+
onChange={(e) => setRefInput(e.target.value)}
|
|
343
|
+
/>
|
|
344
|
+
</label>
|
|
345
|
+
</div>
|
|
346
|
+
|
|
347
|
+
<div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
|
|
348
|
+
<button
|
|
349
|
+
type="button"
|
|
350
|
+
style={DIALOG_BTN_SECONDARY_STYLE}
|
|
351
|
+
data-testid="cs-name-manager-use-selection"
|
|
352
|
+
onClick={useSelection}
|
|
353
|
+
>
|
|
354
|
+
Use selection
|
|
355
|
+
</button>
|
|
356
|
+
<span style={{ flex: 1 }} />
|
|
357
|
+
{isEditing && (
|
|
358
|
+
<button
|
|
359
|
+
type="button"
|
|
360
|
+
style={DIALOG_BTN_SECONDARY_STYLE}
|
|
361
|
+
data-testid="cs-name-manager-cancel-edit"
|
|
362
|
+
onClick={resetForm}
|
|
363
|
+
>
|
|
364
|
+
Cancel
|
|
365
|
+
</button>
|
|
366
|
+
)}
|
|
367
|
+
<button
|
|
368
|
+
type="button"
|
|
369
|
+
style={DIALOG_BTN_PRIMARY_STYLE}
|
|
370
|
+
data-testid="cs-name-manager-submit"
|
|
371
|
+
onClick={submit}
|
|
372
|
+
>
|
|
373
|
+
{isEditing ? 'Update' : 'Add'}
|
|
374
|
+
</button>
|
|
375
|
+
</div>
|
|
376
|
+
</Dialog>
|
|
377
|
+
);
|
|
378
|
+
}
|