@casualoffice/sheets 0.17.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.
@@ -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
+ }
@@ -0,0 +1,286 @@
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
+ * PasteSpecialDialog — the SDK chrome's built-in Paste Special modal.
19
+ *
20
+ * Copies the DataValidationDialog/FormatCellsDialog structure: reads the active
21
+ * A1 selection off the FUniver facade, gathers a mode via a small form, and
22
+ * applies through real Univer commands / facade calls (no app context).
23
+ *
24
+ * Modes → mechanism (all grounded in the INSTALLED @univerjs/sheets-ui build):
25
+ * - values → `sheet.command.paste` with `{ value: 'special-paste-value' }`
26
+ * - formats → `sheet.command.paste` with `{ value: 'special-paste-format' }`
27
+ * - formulas → `sheet.command.paste` with `{ value: 'special-paste-formula' }`
28
+ *
29
+ * The special-paste hook names live in `PREDEFINED_HOOK_NAME_PASTE`
30
+ * (sheets-ui/lib/es/index.js: SPECIAL_PASTE_VALUE/FORMAT/FORMULA), and the
31
+ * dedicated `SheetPasteValueCommand`/`SheetPasteFormatCommand` handlers just
32
+ * forward `SheetPasteCommand.id` (= 'sheet.command.paste', name verified in
33
+ * the same build) with those `{ value }` params. We dispatch the base
34
+ * `sheet.command.paste` with the matching hook value so all three modes go
35
+ * through one real, installed command — including 'formulas', which has no
36
+ * dedicated `sheet.command.paste-*` id but IS a valid paste hook.
37
+ *
38
+ * - transpose → in-place matrix flip via the sheets facade:
39
+ * `FRange.getValues()` → transpose → `getRange(row, col, cols, rows)` →
40
+ * `.setValues()`. There is NO transpose paste command anywhere in the
41
+ * installed sheets/sheets-ui build (grep 'transpose' returns nothing), so
42
+ * rather than stub it we transpose the CURRENT selection in place using the
43
+ * real `getValues`/`getRange`/`setValues` facade methods (verified in
44
+ * @univerjs/sheets/facade f-range.d.ts + f-worksheet.d.ts). See the
45
+ * `limitations` note: this transposes the selection itself, not clipboard
46
+ * contents (the facade exposes no clipboard-transpose primitive).
47
+ *
48
+ * Mounted by `<DialogHost>` when `openDialog('paste-special')` is called and no
49
+ * host override is registered.
50
+ */
51
+
52
+ import { useMemo, useState, type CSSProperties } from 'react';
53
+ // Side-effect import: installs the sheets-ui facade extensions and registers
54
+ // the clipboard/paste commands on the FUniver facade so
55
+ // `executeCommand('sheet.command.paste', …)` resolves at runtime. Mirrors the
56
+ // `@univerjs/sheets/facade` side-effect import the SDK's api.ts already does for
57
+ // the core sheets mixins.
58
+ import '@univerjs/sheets-ui/facade';
59
+ import type { DialogComponentProps } from './extensions';
60
+ import type { CasualSheetsAPI } from '../sheets/api';
61
+ import { Dialog } from './Dialog';
62
+ import {
63
+ DIALOG_BTN_PRIMARY_STYLE,
64
+ DIALOG_BTN_SECONDARY_STYLE,
65
+ DIALOG_FIELD_STYLE,
66
+ DIALOG_LABEL_STYLE,
67
+ } from './dialog-styles';
68
+
69
+ /** Paste-special modes this dialog offers. */
70
+ type PasteMode = 'values' | 'formats' | 'formulas' | 'transpose';
71
+
72
+ const MODE_OPTIONS: Array<{ value: PasteMode; label: string; hint: string }> = [
73
+ {
74
+ value: 'values',
75
+ label: 'Values only',
76
+ hint: 'Paste cell values, dropping formatting and formulas.',
77
+ },
78
+ {
79
+ value: 'formats',
80
+ label: 'Formats only',
81
+ hint: 'Paste number formats, fonts, borders and fills — no values.',
82
+ },
83
+ {
84
+ value: 'formulas',
85
+ label: 'Formulas only',
86
+ hint: 'Paste formulas, adjusting relative references.',
87
+ },
88
+ {
89
+ value: 'transpose',
90
+ label: 'Transpose',
91
+ hint: 'Flip the current selection — rows become columns and columns become rows, in place.',
92
+ },
93
+ ];
94
+
95
+ /** Paste hook names from sheets-ui `PREDEFINED_HOOK_NAME_PASTE`. */
96
+ const PASTE_HOOK: Record<'values' | 'formats' | 'formulas', string> = {
97
+ values: 'special-paste-value',
98
+ formats: 'special-paste-format',
99
+ formulas: 'special-paste-formula',
100
+ };
101
+
102
+ /** The base sheets paste command; the special modes are `{ value: <hook> }`. */
103
+ const SHEET_PASTE_COMMAND = 'sheet.command.paste';
104
+
105
+ /** Minimal shape of the FRange we lean on for the transpose path. */
106
+ interface TransposableRange {
107
+ getRow(): number;
108
+ getColumn(): number;
109
+ getWidth(): number;
110
+ getHeight(): number;
111
+ getValues(): unknown[][];
112
+ getA1Notation?(): string;
113
+ }
114
+
115
+ /** The active FRange, or null when there is no selection. */
116
+ function activeRange(api: CasualSheetsAPI) {
117
+ return api.univer.getActiveWorkbook()?.getActiveSheet()?.getActiveRange() ?? null;
118
+ }
119
+
120
+ /**
121
+ * Transpose the current selection in place using the sheets facade: read the
122
+ * value matrix, flip it, then write it back into a range whose rows/columns are
123
+ * swapped, anchored at the same top-left cell. Returns false when there is no
124
+ * selection or nothing to transpose.
125
+ */
126
+ function transposeSelection(api: CasualSheetsAPI): boolean {
127
+ const sheet = api.univer.getActiveWorkbook()?.getActiveSheet();
128
+ const range = sheet?.getActiveRange() as unknown as TransposableRange | null;
129
+ if (!sheet || !range) return false;
130
+
131
+ const rows = range.getHeight();
132
+ const cols = range.getWidth();
133
+ if (rows <= 0 || cols <= 0) return false;
134
+
135
+ const values = range.getValues();
136
+ const transposed: unknown[][] = [];
137
+ for (let c = 0; c < cols; c++) {
138
+ const newRow: unknown[] = [];
139
+ for (let r = 0; r < rows; r++) {
140
+ newRow.push(values[r]?.[c] ?? null);
141
+ }
142
+ transposed.push(newRow);
143
+ }
144
+
145
+ // New range: same anchor, swapped dimensions (cols rows × rows columns).
146
+ const target = sheet.getRange(range.getRow(), range.getColumn(), cols, rows) as unknown as {
147
+ setValues: (v: unknown[][]) => unknown;
148
+ };
149
+ target.setValues(transposed);
150
+ return true;
151
+ }
152
+
153
+ /**
154
+ * Apply the chosen paste-special mode. Values/formats/formulas dispatch the real
155
+ * `sheet.command.paste` with the matching special-paste hook; transpose flips the
156
+ * selection in place. Returns a promise that resolves to whether it ran.
157
+ */
158
+ async function applyPasteSpecial(api: CasualSheetsAPI, mode: PasteMode): Promise<boolean> {
159
+ if (activeRange(api) === null) return false;
160
+
161
+ if (mode === 'transpose') {
162
+ return transposeSelection(api);
163
+ }
164
+
165
+ return api.executeCommand(SHEET_PASTE_COMMAND, { value: PASTE_HOOK[mode] });
166
+ }
167
+
168
+ const MODE_RADIO_ROW_STYLE: CSSProperties = {
169
+ display: 'flex',
170
+ alignItems: 'flex-start',
171
+ gap: 8,
172
+ padding: '8px 10px',
173
+ border: '1px solid var(--cs-chrome-border, #cdd3db)',
174
+ borderRadius: 6,
175
+ marginBottom: 8,
176
+ cursor: 'pointer',
177
+ };
178
+
179
+ const MODE_RADIO_ROW_ACTIVE_STYLE: CSSProperties = {
180
+ ...MODE_RADIO_ROW_STYLE,
181
+ borderColor: 'var(--cs-chrome-active-fg, #0e7490)',
182
+ background: 'var(--cs-chrome-active-bg, rgba(14, 116, 144, 0.06))',
183
+ };
184
+
185
+ const MODE_LABEL_STYLE: CSSProperties = {
186
+ fontSize: 13,
187
+ fontWeight: 500,
188
+ color: 'var(--cs-chrome-fg, #201f1e)',
189
+ };
190
+
191
+ const MODE_HINT_STYLE: CSSProperties = {
192
+ fontSize: 12,
193
+ color: 'var(--cs-chrome-muted, #605e5c)',
194
+ marginTop: 2,
195
+ lineHeight: 1.35,
196
+ };
197
+
198
+ const RANGE_NOTE_STYLE: CSSProperties = {
199
+ fontSize: 12,
200
+ color: 'var(--cs-chrome-muted, #605e5c)',
201
+ marginBottom: 12,
202
+ };
203
+
204
+ export function PasteSpecialDialog({ api, onClose }: DialogComponentProps) {
205
+ const [mode, setMode] = useState<PasteMode>('values');
206
+
207
+ // Read the selection once for the header hint (getA1Notation is verified on
208
+ // the sheets facade FRange — f-range.d.ts).
209
+ const rangeLabel = useMemo(() => {
210
+ const fRange = activeRange(api) as unknown as { getA1Notation?: () => string } | null;
211
+ return fRange?.getA1Notation?.() ?? null;
212
+ }, [api]);
213
+
214
+ const hasSelection = activeRange(api) !== null;
215
+
216
+ const apply = () => {
217
+ void applyPasteSpecial(api, mode).then((ok) => {
218
+ if (ok) onClose();
219
+ });
220
+ };
221
+
222
+ return (
223
+ <Dialog
224
+ title="Paste special"
225
+ onClose={onClose}
226
+ width={440}
227
+ data-testid="cs-paste-special-dialog"
228
+ footer={
229
+ <>
230
+ <button type="button" style={DIALOG_BTN_SECONDARY_STYLE} onClick={onClose}>
231
+ Cancel
232
+ </button>
233
+ <button
234
+ type="button"
235
+ style={DIALOG_BTN_PRIMARY_STYLE}
236
+ data-testid="cs-paste-special-apply"
237
+ disabled={!hasSelection}
238
+ onClick={apply}
239
+ >
240
+ Paste
241
+ </button>
242
+ </>
243
+ }
244
+ >
245
+ {hasSelection ? (
246
+ <div style={RANGE_NOTE_STYLE} data-testid="cs-paste-special-range">
247
+ {mode === 'transpose' ? 'Transposes' : 'Pastes into'}{' '}
248
+ <strong>{rangeLabel ?? 'the current selection'}</strong>
249
+ </div>
250
+ ) : (
251
+ <div style={RANGE_NOTE_STYLE} data-testid="cs-paste-special-no-selection">
252
+ Select the destination cell(s) first, then reopen this dialog.
253
+ </div>
254
+ )}
255
+
256
+ <div style={DIALOG_FIELD_STYLE}>
257
+ <span style={DIALOG_LABEL_STYLE}>Paste</span>
258
+ <div role="radiogroup" aria-label="Paste special mode">
259
+ {MODE_OPTIONS.map((opt) => {
260
+ const active = mode === opt.value;
261
+ return (
262
+ <label
263
+ key={opt.value}
264
+ style={active ? MODE_RADIO_ROW_ACTIVE_STYLE : MODE_RADIO_ROW_STYLE}
265
+ data-testid={`cs-paste-special-mode-${opt.value}`}
266
+ >
267
+ <input
268
+ type="radio"
269
+ name="cs-paste-special-mode"
270
+ value={opt.value}
271
+ checked={active}
272
+ onChange={() => setMode(opt.value)}
273
+ style={{ marginTop: 2 }}
274
+ />
275
+ <span>
276
+ <span style={MODE_LABEL_STYLE}>{opt.label}</span>
277
+ <span style={MODE_HINT_STYLE}>{opt.hint}</span>
278
+ </span>
279
+ </label>
280
+ );
281
+ })}
282
+ </div>
283
+ </div>
284
+ </Dialog>
285
+ );
286
+ }
@@ -40,6 +40,18 @@ import { createContext, useCallback, useContext, useMemo, useState, type ReactNo
40
40
  import type { CasualSheetsAPI } from '../sheets/api';
41
41
  import type { ChromeExtensions, DialogComponentProps } from './extensions';
42
42
  import { FormatCellsDialog } from './FormatCellsDialog';
43
+ import { DataValidationDialog } from './DataValidationDialog';
44
+ import { ConditionalFormattingDialog } from './ConditionalFormattingDialog';
45
+ import { CustomSortDialog } from './CustomSortDialog';
46
+ import { PasteSpecialDialog } from './PasteSpecialDialog';
47
+ import { InsertFunctionDialog } from './InsertFunctionDialog';
48
+ import { NameManagerDialog } from './NameManagerDialog';
49
+ import { InsertCellsDialog } from './InsertCellsDialog';
50
+ import { DeleteCellsDialog } from './DeleteCellsDialog';
51
+ import { GoalSeekDialog } from './GoalSeekDialog';
52
+ import { InsertChartDialog } from './InsertChartDialog';
53
+ import { InsertSparklineDialog } from './InsertSparklineDialog';
54
+ import { InsertPivotDialog } from './InsertPivotDialog';
43
55
 
44
56
  /**
45
57
  * All dialog kinds the chrome knows about. Mirrors `MenuDialogKind` in MenuBar
@@ -76,6 +88,18 @@ export type DialogKind =
76
88
  */
77
89
  const BUILT_IN_DIALOGS: Partial<Record<DialogKind, React.ComponentType<DialogComponentProps>>> = {
78
90
  'format-cells': FormatCellsDialog,
91
+ 'data-validation': DataValidationDialog,
92
+ 'conditional-formatting': ConditionalFormattingDialog,
93
+ 'custom-sort': CustomSortDialog,
94
+ 'paste-special': PasteSpecialDialog,
95
+ 'insert-function': InsertFunctionDialog,
96
+ 'name-manager': NameManagerDialog,
97
+ 'insert-cells': InsertCellsDialog,
98
+ 'delete-cells': DeleteCellsDialog,
99
+ 'goal-seek': GoalSeekDialog,
100
+ 'insert-chart': InsertChartDialog,
101
+ 'insert-sparkline': InsertSparklineDialog,
102
+ 'insert-pivot': InsertPivotDialog,
79
103
  };
80
104
 
81
105
  /** Kinds the chrome can open without a host (built-in modal or self-managing). */