@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
|
@@ -0,0 +1,357 @@
|
|
|
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
|
+
* CustomSortDialog — the SDK chrome's built-in "Sort range" modal.
|
|
19
|
+
*
|
|
20
|
+
* Multi-level sort of the active A1 selection: the user adds one or more sort
|
|
21
|
+
* levels (a column + ascending/descending), plus a "data has header row" toggle
|
|
22
|
+
* that keeps the header in place and labels the column pickers with the header
|
|
23
|
+
* text. Applied through the `@univerjs/sheets-sort` facade — the
|
|
24
|
+
* `FRange.sort(SortColumnSpec | SortColumnSpec[])` extension, where
|
|
25
|
+
* `SortColumnSpec = { column: number; ascending: boolean } | number` and
|
|
26
|
+
* `column` is 0-based **relative to the sorted range's first column**
|
|
27
|
+
* (verified in `@univerjs/sheets-sort/lib/types/facade/f-range.d.ts` L17-49).
|
|
28
|
+
*
|
|
29
|
+
* The facade `sort()` has no first-class "has header" option, so the header row
|
|
30
|
+
* is honoured by sorting a sub-range that starts one row below the selection's
|
|
31
|
+
* top (`FWorksheet.getRange(row, col, numRows, numCols)`, verified in
|
|
32
|
+
* `@univerjs/sheets/lib/types/facade/f-worksheet.d.ts` L258) — the header row is
|
|
33
|
+
* simply excluded from the sorted band, so it stays put.
|
|
34
|
+
*
|
|
35
|
+
* Mounted by `<DialogHost>` when `openDialog('custom-sort')` is called and no
|
|
36
|
+
* host override is registered.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import { useMemo, useState, type CSSProperties } from 'react';
|
|
40
|
+
// Side-effect import: installs `FRange.sort()` (and the sort mixin's TS
|
|
41
|
+
// augmentation of `@univerjs/sheets/facade`) on the facade prototype. The sort
|
|
42
|
+
// plugin registers the command but does NOT import this facade module, so
|
|
43
|
+
// without it `range.sort(...)` is undefined at runtime. Mirrors the
|
|
44
|
+
// `sheets-data-validation/facade` side-effect import in DataValidationDialog.
|
|
45
|
+
import '@univerjs/sheets-sort/facade';
|
|
46
|
+
import type { DialogComponentProps } from './extensions';
|
|
47
|
+
import type { CasualSheetsAPI } from '../sheets/api';
|
|
48
|
+
import { Dialog } from './Dialog';
|
|
49
|
+
import {
|
|
50
|
+
DIALOG_BTN_PRIMARY_STYLE,
|
|
51
|
+
DIALOG_BTN_SECONDARY_STYLE,
|
|
52
|
+
DIALOG_FIELD_STYLE,
|
|
53
|
+
DIALOG_INPUT_STYLE,
|
|
54
|
+
DIALOG_LABEL_STYLE,
|
|
55
|
+
} from './dialog-styles';
|
|
56
|
+
|
|
57
|
+
/** One sort level: a range-relative column offset + direction. */
|
|
58
|
+
interface SortLevel {
|
|
59
|
+
/** 0-based offset from the range's first column. */
|
|
60
|
+
column: number;
|
|
61
|
+
ascending: boolean;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The `SortColumnSpec` object shape from the sheets-sort facade. */
|
|
65
|
+
type SortColumnSpec = { column: number; ascending: boolean };
|
|
66
|
+
|
|
67
|
+
/** The active FRange, or null when there is no selection. */
|
|
68
|
+
function activeRange(api: CasualSheetsAPI) {
|
|
69
|
+
return api.univer.getActiveWorkbook()?.getActiveSheet()?.getActiveRange() ?? null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Minimal shape we lean on off the live FRange (grounded in sheets/facade). */
|
|
73
|
+
interface RangeGeom {
|
|
74
|
+
getRow(): number;
|
|
75
|
+
getColumn(): number;
|
|
76
|
+
getWidth(): number;
|
|
77
|
+
getHeight(): number;
|
|
78
|
+
getA1Notation?: () => string;
|
|
79
|
+
getValues?: () => Array<Array<unknown>>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Convert a 0-based absolute column index to a spreadsheet letter (A, B, …, AA). */
|
|
83
|
+
function columnLetter(index: number): string {
|
|
84
|
+
let n = index;
|
|
85
|
+
let label = '';
|
|
86
|
+
do {
|
|
87
|
+
label = String.fromCharCode(65 + (n % 26)) + label;
|
|
88
|
+
n = Math.floor(n / 26) - 1;
|
|
89
|
+
} while (n >= 0);
|
|
90
|
+
return label;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Read geometry + optional header labels off the active range. */
|
|
94
|
+
interface RangeInfo {
|
|
95
|
+
/** First (absolute) row of the selection. */
|
|
96
|
+
startRow: number;
|
|
97
|
+
/** First (absolute) column of the selection. */
|
|
98
|
+
startColumn: number;
|
|
99
|
+
/** Number of columns in the selection. */
|
|
100
|
+
width: number;
|
|
101
|
+
/** Number of rows in the selection. */
|
|
102
|
+
height: number;
|
|
103
|
+
/** A1 label for the header note, e.g. "A1:C10". */
|
|
104
|
+
a1: string | null;
|
|
105
|
+
/** Values of the first row (header candidates), if readable. */
|
|
106
|
+
firstRow: Array<unknown> | null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function readRangeInfo(api: CasualSheetsAPI): RangeInfo | null {
|
|
110
|
+
const range = activeRange(api) as unknown as RangeGeom | null;
|
|
111
|
+
if (!range) return null;
|
|
112
|
+
const values = range.getValues?.();
|
|
113
|
+
return {
|
|
114
|
+
startRow: range.getRow(),
|
|
115
|
+
startColumn: range.getColumn(),
|
|
116
|
+
width: range.getWidth(),
|
|
117
|
+
height: range.getHeight(),
|
|
118
|
+
a1: range.getA1Notation?.() ?? null,
|
|
119
|
+
firstRow: values && values.length > 0 ? values[0] : null,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Apply the multi-level sort via the sheets-sort facade. Returns false when
|
|
125
|
+
* there's no selection or nothing sensible to sort.
|
|
126
|
+
*/
|
|
127
|
+
function applySort(
|
|
128
|
+
api: CasualSheetsAPI,
|
|
129
|
+
info: RangeInfo,
|
|
130
|
+
levels: SortLevel[],
|
|
131
|
+
hasHeader: boolean,
|
|
132
|
+
): boolean {
|
|
133
|
+
const worksheet = api.univer.getActiveWorkbook()?.getActiveSheet();
|
|
134
|
+
if (!worksheet) return false;
|
|
135
|
+
|
|
136
|
+
// When the data has a header row, exclude it from the sorted band so it stays
|
|
137
|
+
// pinned at the top. Otherwise sort the whole selection.
|
|
138
|
+
const bandStartRow = hasHeader ? info.startRow + 1 : info.startRow;
|
|
139
|
+
const bandHeight = hasHeader ? info.height - 1 : info.height;
|
|
140
|
+
if (bandHeight <= 0) return false;
|
|
141
|
+
|
|
142
|
+
// getRange(row, column, numRows, numColumns) — sheets/facade f-worksheet.d.ts L258.
|
|
143
|
+
const band = (
|
|
144
|
+
worksheet as unknown as {
|
|
145
|
+
getRange: (row: number, col: number, numRows: number, numCols: number) => unknown;
|
|
146
|
+
}
|
|
147
|
+
).getRange(bandStartRow, info.startColumn, bandHeight, info.width);
|
|
148
|
+
|
|
149
|
+
// Clamp each level's column offset to the range and drop invalid ones.
|
|
150
|
+
const specs: SortColumnSpec[] = levels
|
|
151
|
+
.filter((l) => l.column >= 0 && l.column < info.width)
|
|
152
|
+
.map((l) => ({ column: l.column, ascending: l.ascending }));
|
|
153
|
+
if (specs.length === 0) return false;
|
|
154
|
+
|
|
155
|
+
// FRange.sort(SortColumnSpec[]) — sheets-sort/facade f-range.d.ts L46. Column
|
|
156
|
+
// indices are relative to the (sub-)range's first column, which is exactly the
|
|
157
|
+
// 0-based offset the pickers produce.
|
|
158
|
+
(band as { sort: (c: SortColumnSpec | SortColumnSpec[]) => unknown }).sort(specs);
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const RANGE_NOTE_STYLE: CSSProperties = {
|
|
163
|
+
fontSize: 12,
|
|
164
|
+
color: 'var(--cs-chrome-muted, #605e5c)',
|
|
165
|
+
marginBottom: 12,
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const CHECK_STYLE: CSSProperties = {
|
|
169
|
+
display: 'flex',
|
|
170
|
+
alignItems: 'center',
|
|
171
|
+
gap: 6,
|
|
172
|
+
marginBottom: 12,
|
|
173
|
+
cursor: 'pointer',
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const LEVEL_ROW_STYLE: CSSProperties = {
|
|
177
|
+
display: 'grid',
|
|
178
|
+
gridTemplateColumns: 'auto 1fr 130px 28px',
|
|
179
|
+
alignItems: 'center',
|
|
180
|
+
gap: 8,
|
|
181
|
+
marginBottom: 8,
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const LEVEL_PREFIX_STYLE: CSSProperties = {
|
|
185
|
+
fontSize: 12,
|
|
186
|
+
color: 'var(--cs-chrome-muted, #605e5c)',
|
|
187
|
+
whiteSpace: 'nowrap',
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const REMOVE_BTN_STYLE: CSSProperties = {
|
|
191
|
+
width: 28,
|
|
192
|
+
height: 30,
|
|
193
|
+
display: 'inline-flex',
|
|
194
|
+
alignItems: 'center',
|
|
195
|
+
justifyContent: 'center',
|
|
196
|
+
border: '1px solid var(--cs-chrome-border, #cdd3db)',
|
|
197
|
+
borderRadius: 6,
|
|
198
|
+
background: 'var(--cs-chrome-input-bg, #fff)',
|
|
199
|
+
color: 'var(--cs-chrome-muted, #605e5c)',
|
|
200
|
+
cursor: 'pointer',
|
|
201
|
+
padding: 0,
|
|
202
|
+
fontSize: 16,
|
|
203
|
+
lineHeight: 1,
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const ADD_BTN_STYLE: CSSProperties = {
|
|
207
|
+
...DIALOG_BTN_SECONDARY_STYLE,
|
|
208
|
+
marginTop: 4,
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
export function CustomSortDialog({ api, onClose }: DialogComponentProps) {
|
|
212
|
+
// Read the selection once when the dialog mounts.
|
|
213
|
+
const info = useMemo(() => readRangeInfo(api), [api]);
|
|
214
|
+
const hasSelection = info !== null;
|
|
215
|
+
const width = info?.width ?? 1;
|
|
216
|
+
|
|
217
|
+
const [hasHeader, setHasHeader] = useState(false);
|
|
218
|
+
const [levels, setLevels] = useState<SortLevel[]>([{ column: 0, ascending: true }]);
|
|
219
|
+
|
|
220
|
+
// Column option labels: header text when "has header" is on and text exists,
|
|
221
|
+
// otherwise the absolute spreadsheet column letter.
|
|
222
|
+
const columnOptions = useMemo(() => {
|
|
223
|
+
const startCol = info?.startColumn ?? 0;
|
|
224
|
+
return Array.from({ length: width }, (_, offset) => {
|
|
225
|
+
const headerCell = hasHeader ? info?.firstRow?.[offset] : undefined;
|
|
226
|
+
const headerText =
|
|
227
|
+
headerCell !== undefined && headerCell !== null && String(headerCell).trim() !== ''
|
|
228
|
+
? String(headerCell)
|
|
229
|
+
: null;
|
|
230
|
+
const letter = columnLetter(startCol + offset);
|
|
231
|
+
return {
|
|
232
|
+
value: offset,
|
|
233
|
+
label: headerText ? `${headerText} (${letter})` : `Column ${letter}`,
|
|
234
|
+
};
|
|
235
|
+
});
|
|
236
|
+
}, [width, hasHeader, info]);
|
|
237
|
+
|
|
238
|
+
const updateLevel = (index: number, patch: Partial<SortLevel>) =>
|
|
239
|
+
setLevels((prev) => prev.map((l, i) => (i === index ? { ...l, ...patch } : l)));
|
|
240
|
+
|
|
241
|
+
const addLevel = () => {
|
|
242
|
+
// Default the new level to the first column not already used, else column 0.
|
|
243
|
+
const used = new Set(levels.map((l) => l.column));
|
|
244
|
+
let next = 0;
|
|
245
|
+
for (let c = 0; c < width; c += 1) {
|
|
246
|
+
if (!used.has(c)) {
|
|
247
|
+
next = c;
|
|
248
|
+
break;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
setLevels((prev) => [...prev, { column: next, ascending: true }]);
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
const removeLevel = (index: number) =>
|
|
255
|
+
setLevels((prev) => (prev.length <= 1 ? prev : prev.filter((_, i) => i !== index)));
|
|
256
|
+
|
|
257
|
+
const apply = () => {
|
|
258
|
+
if (info && applySort(api, info, levels, hasHeader)) onClose();
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
const canApply = hasSelection && levels.length > 0;
|
|
262
|
+
|
|
263
|
+
return (
|
|
264
|
+
<Dialog
|
|
265
|
+
title="Sort range"
|
|
266
|
+
onClose={onClose}
|
|
267
|
+
width={480}
|
|
268
|
+
data-testid="cs-custom-sort-dialog"
|
|
269
|
+
footer={
|
|
270
|
+
<>
|
|
271
|
+
<button type="button" style={DIALOG_BTN_SECONDARY_STYLE} onClick={onClose}>
|
|
272
|
+
Cancel
|
|
273
|
+
</button>
|
|
274
|
+
<button
|
|
275
|
+
type="button"
|
|
276
|
+
style={DIALOG_BTN_PRIMARY_STYLE}
|
|
277
|
+
data-testid="cs-custom-sort-apply"
|
|
278
|
+
disabled={!canApply}
|
|
279
|
+
onClick={apply}
|
|
280
|
+
>
|
|
281
|
+
Sort
|
|
282
|
+
</button>
|
|
283
|
+
</>
|
|
284
|
+
}
|
|
285
|
+
>
|
|
286
|
+
{hasSelection ? (
|
|
287
|
+
<div style={RANGE_NOTE_STYLE} data-testid="cs-custom-sort-range">
|
|
288
|
+
Sort <strong>{info?.a1 ?? 'the current selection'}</strong>
|
|
289
|
+
</div>
|
|
290
|
+
) : (
|
|
291
|
+
<div style={RANGE_NOTE_STYLE} data-testid="cs-custom-sort-no-selection">
|
|
292
|
+
Select the range you want to sort first, then reopen this dialog.
|
|
293
|
+
</div>
|
|
294
|
+
)}
|
|
295
|
+
|
|
296
|
+
<label style={CHECK_STYLE} data-testid="cs-custom-sort-has-header-label">
|
|
297
|
+
<input
|
|
298
|
+
type="checkbox"
|
|
299
|
+
data-testid="cs-custom-sort-has-header"
|
|
300
|
+
checked={hasHeader}
|
|
301
|
+
onChange={(e) => setHasHeader(e.target.checked)}
|
|
302
|
+
/>
|
|
303
|
+
<span>Data has header row</span>
|
|
304
|
+
</label>
|
|
305
|
+
|
|
306
|
+
<div style={DIALOG_FIELD_STYLE}>
|
|
307
|
+
<span style={DIALOG_LABEL_STYLE}>Sort by</span>
|
|
308
|
+
{levels.map((level, index) => (
|
|
309
|
+
<div key={index} style={LEVEL_ROW_STYLE} data-testid={`cs-custom-sort-level-${index}`}>
|
|
310
|
+
<span style={LEVEL_PREFIX_STYLE}>{index === 0 ? 'Sort by' : 'then by'}</span>
|
|
311
|
+
<select
|
|
312
|
+
style={DIALOG_INPUT_STYLE}
|
|
313
|
+
data-testid={`cs-custom-sort-column-${index}`}
|
|
314
|
+
value={level.column}
|
|
315
|
+
onChange={(e) => updateLevel(index, { column: Number(e.target.value) })}
|
|
316
|
+
>
|
|
317
|
+
{columnOptions.map((opt) => (
|
|
318
|
+
<option key={opt.value} value={opt.value}>
|
|
319
|
+
{opt.label}
|
|
320
|
+
</option>
|
|
321
|
+
))}
|
|
322
|
+
</select>
|
|
323
|
+
<select
|
|
324
|
+
style={DIALOG_INPUT_STYLE}
|
|
325
|
+
data-testid={`cs-custom-sort-order-${index}`}
|
|
326
|
+
value={level.ascending ? 'asc' : 'desc'}
|
|
327
|
+
onChange={(e) => updateLevel(index, { ascending: e.target.value === 'asc' })}
|
|
328
|
+
>
|
|
329
|
+
<option value="asc">A → Z (ascending)</option>
|
|
330
|
+
<option value="desc">Z → A (descending)</option>
|
|
331
|
+
</select>
|
|
332
|
+
<button
|
|
333
|
+
type="button"
|
|
334
|
+
style={REMOVE_BTN_STYLE}
|
|
335
|
+
data-testid={`cs-custom-sort-remove-${index}`}
|
|
336
|
+
aria-label="Remove sort level"
|
|
337
|
+
disabled={levels.length <= 1}
|
|
338
|
+
onClick={() => removeLevel(index)}
|
|
339
|
+
>
|
|
340
|
+
−
|
|
341
|
+
</button>
|
|
342
|
+
</div>
|
|
343
|
+
))}
|
|
344
|
+
|
|
345
|
+
<button
|
|
346
|
+
type="button"
|
|
347
|
+
style={ADD_BTN_STYLE}
|
|
348
|
+
data-testid="cs-custom-sort-add-level"
|
|
349
|
+
disabled={levels.length >= width}
|
|
350
|
+
onClick={addLevel}
|
|
351
|
+
>
|
|
352
|
+
Add another sort level
|
|
353
|
+
</button>
|
|
354
|
+
</div>
|
|
355
|
+
</Dialog>
|
|
356
|
+
);
|
|
357
|
+
}
|