@casualoffice/sheets 0.17.0 → 0.19.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.
Files changed (52) hide show
  1. package/dist/chrome.cjs +3339 -244
  2. package/dist/chrome.cjs.map +1 -1
  3. package/dist/chrome.js +3298 -203
  4. package/dist/chrome.js.map +1 -1
  5. package/dist/embed/embed-runtime.js +203 -157
  6. package/dist/index.cjs +5971 -1572
  7. package/dist/index.cjs.map +1 -1
  8. package/dist/index.js +5961 -1548
  9. package/dist/index.js.map +1 -1
  10. package/dist/sheets.cjs +4937 -538
  11. package/dist/sheets.cjs.map +1 -1
  12. package/dist/sheets.js +4945 -532
  13. package/dist/sheets.js.map +1 -1
  14. package/package.json +8 -7
  15. package/src/charts/ChartContextMenu.tsx +264 -0
  16. package/src/charts/ChartLayer.tsx +333 -0
  17. package/src/charts/ChartOverlay.tsx +293 -0
  18. package/src/charts/ChartsPanel.tsx +211 -0
  19. package/src/charts/FormatChartDialog.tsx +419 -0
  20. package/src/charts/InsertChartDialog.tsx +478 -0
  21. package/src/charts/build-option.ts +476 -0
  22. package/src/charts/charts-context.tsx +205 -0
  23. package/src/charts/echarts-init.ts +58 -0
  24. package/src/charts/hit-test.ts +130 -0
  25. package/src/charts/insert-chart.ts +106 -0
  26. package/src/charts/naming.ts +38 -0
  27. package/src/charts/render-to-png.ts +117 -0
  28. package/src/charts/resources.ts +108 -0
  29. package/src/charts/types.ts +239 -0
  30. package/src/charts/univer-dom.ts +102 -0
  31. package/src/chrome/CommentsPanel.tsx +427 -0
  32. package/src/chrome/ConditionalFormattingDialog.tsx +534 -0
  33. package/src/chrome/CustomSortDialog.tsx +357 -0
  34. package/src/chrome/DataValidationDialog.tsx +536 -0
  35. package/src/chrome/DeleteCellsDialog.tsx +183 -0
  36. package/src/chrome/GoalSeekDialog.tsx +370 -0
  37. package/src/chrome/HistoryPanel.tsx +319 -0
  38. package/src/chrome/InsertCellsDialog.tsx +185 -0
  39. package/src/chrome/InsertChartDialog.tsx +490 -0
  40. package/src/chrome/InsertFunctionDialog.tsx +493 -0
  41. package/src/chrome/InsertPivotDialog.tsx +488 -0
  42. package/src/chrome/InsertSparklineDialog.tsx +344 -0
  43. package/src/chrome/NameManagerDialog.tsx +378 -0
  44. package/src/chrome/PanelHost.tsx +55 -0
  45. package/src/chrome/PanelRail.tsx +90 -0
  46. package/src/chrome/PasteSpecialDialog.tsx +286 -0
  47. package/src/chrome/PivotFieldsPanel.tsx +1052 -0
  48. package/src/chrome/TablesPanel.tsx +301 -0
  49. package/src/chrome/dialog-context.tsx +24 -0
  50. package/src/chrome/panel-context.tsx +55 -0
  51. package/src/chrome/panel-registry.ts +48 -0
  52. package/src/sheets/CasualSheets.tsx +60 -34
@@ -0,0 +1,319 @@
1
+ /**
2
+ * Copyright 2026 Casual Office
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License").
5
+ */
6
+
7
+ /**
8
+ * History / Activity side panel — a self-contained, in-memory activity feed
9
+ * of user-facing edits in the current session.
10
+ *
11
+ * Ported from the standalone app's HistoryPanel, but DECOUPLED from every
12
+ * app-only coupling: the app version drove off two host-owned sources (a
13
+ * shared Yjs op-log and IndexedDB-backed snapshot version history via
14
+ * `useCollab`/`usePresence`/`local-history`). None of that exists in the SDK,
15
+ * and persistent version history stays HOST-OWNED. So instead this panel
16
+ * subscribes to the live `ICommandService.onCommandExecuted` stream, keeps a
17
+ * bounded in-memory ring (last 100) of user-facing command executions, maps
18
+ * each command id to a friendly label, and renders them newest-first.
19
+ *
20
+ * Ordering is derived from a monotonically incrementing sequence counter
21
+ * (NOT wall-clock time) so nothing calls `Date.now()`/`new Date()` at module
22
+ * load; a per-entry relative-age string is computed lazily at render time from
23
+ * a capture timestamp taken inside the (post-mount) event handler.
24
+ *
25
+ * The app-only `useUI().toggleHistoryPanel` is replaced by the panel host's
26
+ * `onClose`; the Univer facade + services are reached through the `api` prop's
27
+ * `_injector`, matching StatusBar / FormulaBar precedent.
28
+ */
29
+ import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
30
+ import { CommandType, ICommandService, type ICommandInfo } from '@univerjs/core';
31
+
32
+ import type { PanelComponentProps } from './extensions';
33
+ import { Icon } from './Icon';
34
+
35
+ const RING_CAP = 100;
36
+
37
+ interface ActivityEntry {
38
+ /** Monotonic sequence — sole source of ordering (no wall-clock at module load). */
39
+ seq: number;
40
+ /** Command id that fired (e.g. `sheet.command.set-range-values`). */
41
+ id: string;
42
+ /** Friendly one-line label. */
43
+ label: string;
44
+ /** Icon glyph for the row. */
45
+ icon: string;
46
+ /** Capture time (ms). Taken inside the handler, post-mount — safe. */
47
+ t: number;
48
+ }
49
+
50
+ /**
51
+ * Map a user-facing COMMAND id to a friendly label + icon. Commands (not
52
+ * mutations) are the user-intent layer, so one command == one activity row —
53
+ * no de-duping a single paste into a dozen mutation rows. Anything not mapped
54
+ * still records with a de-namespaced fallback label so the feed stays honest.
55
+ */
56
+ const COMMAND_LABELS: Record<string, { label: string; icon: string }> = {
57
+ 'sheet.command.set-range-values': { label: 'Edited cells', icon: 'edit' },
58
+ 'sheet.command.set-range-bold': { label: 'Toggled bold', icon: 'format_bold' },
59
+ 'sheet.command.set-range-italic': { label: 'Toggled italic', icon: 'format_italic' },
60
+ 'sheet.command.set-range-underline': { label: 'Toggled underline', icon: 'format_underlined' },
61
+ 'sheet.command.set-range-stroke-through': {
62
+ label: 'Toggled strikethrough',
63
+ icon: 'strikethrough_s',
64
+ },
65
+ 'sheet.command.set-range-font-family': { label: 'Changed font', icon: 'font_download' },
66
+ 'sheet.command.set-range-font-size': { label: 'Changed font size', icon: 'format_size' },
67
+ 'sheet.command.set-range-text-color': { label: 'Changed text color', icon: 'format_color_text' },
68
+ 'sheet.command.set-background-color': { label: 'Changed fill color', icon: 'format_color_fill' },
69
+ 'sheet.command.set-range-text-align': { label: 'Changed alignment', icon: 'format_align_left' },
70
+ 'sheet.command.set-range-vertical-align': {
71
+ label: 'Changed alignment',
72
+ icon: 'vertical_align_center',
73
+ },
74
+ 'sheet.command.set-border-command': { label: 'Changed borders', icon: 'border_all' },
75
+ 'sheet.command.clear-selection-content': { label: 'Cleared cell contents', icon: 'backspace' },
76
+ 'sheet.command.clear-selection-format': { label: 'Cleared formatting', icon: 'format_clear' },
77
+ 'sheet.command.clear-selection-all': { label: 'Cleared cells', icon: 'clear_all' },
78
+ 'sheet.command.insert-row': { label: 'Inserted row(s)', icon: 'add_row_above' },
79
+ 'sheet.command.insert-row-before': { label: 'Inserted row above', icon: 'add_row_above' },
80
+ 'sheet.command.insert-row-after': { label: 'Inserted row below', icon: 'add_row_below' },
81
+ 'sheet.command.insert-col': { label: 'Inserted column(s)', icon: 'add_column_left' },
82
+ 'sheet.command.insert-col-before': { label: 'Inserted column left', icon: 'add_column_left' },
83
+ 'sheet.command.insert-col-after': { label: 'Inserted column right', icon: 'add_column_right' },
84
+ 'sheet.command.remove-row': { label: 'Deleted row(s)', icon: 'delete' },
85
+ 'sheet.command.remove-col': { label: 'Deleted column(s)', icon: 'delete' },
86
+ 'sheet.command.delete-range-move-up': { label: 'Deleted cells', icon: 'delete' },
87
+ 'sheet.command.delete-range-move-left': { label: 'Deleted cells', icon: 'delete' },
88
+ 'sheet.command.insert-range-move-down': { label: 'Inserted cells', icon: 'add' },
89
+ 'sheet.command.insert-range-move-right': { label: 'Inserted cells', icon: 'add' },
90
+ 'sheet.command.move-range': { label: 'Moved cells', icon: 'open_with' },
91
+ 'sheet.command.move-rows': { label: 'Moved row(s)', icon: 'swap_vert' },
92
+ 'sheet.command.move-cols': { label: 'Moved column(s)', icon: 'swap_horiz' },
93
+ 'sheet.command.add-worksheet-merge-all': { label: 'Merged cells', icon: 'cell_merge' },
94
+ 'sheet.command.remove-worksheet-merge': { label: 'Unmerged cells', icon: 'grid_on' },
95
+ 'sheet.command.set-worksheet-row-height': { label: 'Resized row(s)', icon: 'height' },
96
+ 'sheet.command.set-worksheet-col-width': { label: 'Resized column(s)', icon: 'width_normal' },
97
+ 'sheet.command.set-row-hidden': { label: 'Hid row(s)', icon: 'visibility_off' },
98
+ 'sheet.command.set-specific-rows-visible': { label: 'Showed row(s)', icon: 'visibility' },
99
+ 'sheet.command.set-col-hidden': { label: 'Hid column(s)', icon: 'visibility_off' },
100
+ 'sheet.command.set-specific-cols-visible': { label: 'Showed column(s)', icon: 'visibility' },
101
+ 'sheet.command.set-frozen': { label: 'Changed freeze panes', icon: 'ac_unit' },
102
+ 'sheet.command.insert-sheet': { label: 'Added a sheet', icon: 'add_box' },
103
+ 'sheet.command.remove-sheet': { label: 'Removed a sheet', icon: 'delete' },
104
+ 'sheet.command.set-worksheet-name': {
105
+ label: 'Renamed a sheet',
106
+ icon: 'drive_file_rename_outline',
107
+ },
108
+ 'sheet.command.set-worksheet-order': { label: 'Reordered sheets', icon: 'reorder' },
109
+ 'sheet.command.set-tab-color': { label: 'Changed tab color', icon: 'palette' },
110
+ 'sheet.command.paste': { label: 'Pasted', icon: 'content_paste' },
111
+ 'sheet.command.copy': { label: 'Copied', icon: 'content_copy' },
112
+ 'sheet.command.cut': { label: 'Cut', icon: 'content_cut' },
113
+ 'sheet.command.add-table': { label: 'Inserted a table', icon: 'table' },
114
+ 'sheet.command.delete-table': { label: 'Deleted a table', icon: 'delete' },
115
+ 'sheet.command.numfmt.set.numfmt': { label: 'Changed number format', icon: 'tag' },
116
+ 'univer.command.undo': { label: 'Undo', icon: 'undo' },
117
+ 'univer.command.redo': { label: 'Redo', icon: 'redo' },
118
+ };
119
+
120
+ /**
121
+ * Which command ids count as "user-facing activity". We only record
122
+ * `CommandType.COMMAND` (intent) executions, and we skip pure selection /
123
+ * navigation / scroll operations even though some of those are dispatched as
124
+ * COMMANDs — they're noise, not edits. The allow-list above IS the primary
125
+ * signal; unmapped COMMANDs still record with a fallback label unless they
126
+ * match an obvious noise prefix.
127
+ */
128
+ const NOISE_PREFIXES = [
129
+ 'sheet.operation.',
130
+ 'sheet.command.set-selection',
131
+ 'sheet.command.scroll',
132
+ 'sheet.command.set-zoom',
133
+ 'sheet.command.set-activate-cell-edit',
134
+ 'sheet.command.set-cell-edit-visible',
135
+ 'sheet.command.set-editor',
136
+ 'formula.',
137
+ 'univer.command.set-current-locale',
138
+ ];
139
+
140
+ function isNoise(id: string): boolean {
141
+ return NOISE_PREFIXES.some((p) => id.startsWith(p));
142
+ }
143
+
144
+ function labelFor(id: string): { label: string; icon: string } {
145
+ const known = COMMAND_LABELS[id];
146
+ if (known) return known;
147
+ // De-namespace the id into something human-ish.
148
+ const tail = id
149
+ .replace(/^sheet\.command\./, '')
150
+ .replace(/^univer\.command\./, '')
151
+ .replace(/[.-]/g, ' ')
152
+ .trim();
153
+ return { label: tail ? tail.charAt(0).toUpperCase() + tail.slice(1) : 'Change', icon: 'bolt' };
154
+ }
155
+
156
+ function relativeTime(t: number, now: number): string {
157
+ const delta = now - t;
158
+ if (delta < 5_000) return 'just now';
159
+ if (delta < 60_000) return `${Math.floor(delta / 1000)}s ago`;
160
+ if (delta < 3_600_000) return `${Math.floor(delta / 60_000)}m ago`;
161
+ return `${Math.floor(delta / 3_600_000)}h ago`;
162
+ }
163
+
164
+ const headerStyle: CSSProperties = {
165
+ display: 'flex',
166
+ alignItems: 'center',
167
+ gap: 8,
168
+ padding: '10px 12px',
169
+ borderBottom: '1px solid var(--cs-chrome-border, #edeff3)',
170
+ };
171
+
172
+ export function HistoryPanel({ api, onClose }: PanelComponentProps) {
173
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
174
+ const univer = (api as any).univer;
175
+ const [entries, setEntries] = useState<ActivityEntry[]>([]);
176
+ // Ordering counter — monotonic, initialised at 0 (no wall-clock at load).
177
+ const seqRef = useRef(0);
178
+ // Re-render tick so relative times refresh even without new activity.
179
+ const [nowTick, setNowTick] = useState(0);
180
+
181
+ useEffect(() => {
182
+ if (!univer) return;
183
+ const injector = (univer as { _injector?: { get(t: unknown): unknown } })._injector;
184
+ const cmd = injector?.get(ICommandService) as
185
+ | {
186
+ onCommandExecuted: (cb: (info: ICommandInfo, options?: unknown) => void) => {
187
+ dispose: () => void;
188
+ };
189
+ }
190
+ | undefined;
191
+ if (!cmd) return;
192
+
193
+ const sub = cmd.onCommandExecuted((info) => {
194
+ // Only intent-level COMMANDs; mutations/operations would spam the feed.
195
+ // `type` is optional on ICommandInfo but the dispatcher populates it —
196
+ // treat an explicit non-COMMAND type as a reject, and let undefined
197
+ // fall through to the id-based allow-list below.
198
+ if (info.type !== undefined && info.type !== CommandType.COMMAND) return;
199
+ if (isNoise(info.id)) return;
200
+ // Ignore anything not on the allow-list unless it looks like a real
201
+ // sheet command (keeps the feed to genuine edits, not internal churn).
202
+ if (!COMMAND_LABELS[info.id] && !info.id.startsWith('sheet.command.')) return;
203
+
204
+ const { label, icon } = labelFor(info.id);
205
+ const seq = ++seqRef.current;
206
+ const entry: ActivityEntry = { seq, id: info.id, label, icon, t: Date.now() };
207
+ setEntries((prev) => {
208
+ const next = prev.length >= RING_CAP ? prev.slice(prev.length - RING_CAP + 1) : prev;
209
+ return [...next, entry];
210
+ });
211
+ });
212
+ return () => sub.dispose();
213
+ }, [univer]);
214
+
215
+ // Refresh relative timestamps once a minute while the panel is open.
216
+ useEffect(() => {
217
+ const h = setInterval(() => setNowTick((n) => n + 1), 60_000);
218
+ return () => clearInterval(h);
219
+ }, []);
220
+
221
+ // Newest-first. Ordering is purely by seq — the render-time `now` only
222
+ // affects the human-readable age string, never the order.
223
+ const sorted = useMemo(() => [...entries].sort((a, b) => b.seq - a.seq), [entries]);
224
+ const now = useMemo(() => Date.now(), [nowTick, entries.length]);
225
+
226
+ const empty = sorted.length === 0;
227
+
228
+ return (
229
+ <div
230
+ data-testid="cs-history-panel"
231
+ style={{ height: '100%', display: 'flex', flexDirection: 'column' }}
232
+ >
233
+ <header style={headerStyle}>
234
+ <Icon name="history" size={18} />
235
+ <span style={{ fontWeight: 600, flex: 1 }}>Activity</span>
236
+ {!empty && (
237
+ <span data-testid="cs-history-count" style={{ opacity: 0.6, fontSize: 12 }}>
238
+ {sorted.length}
239
+ </span>
240
+ )}
241
+ <button
242
+ type="button"
243
+ aria-label="Close activity panel"
244
+ onClick={onClose}
245
+ style={{ border: 'none', background: 'transparent', cursor: 'pointer', color: 'inherit' }}
246
+ >
247
+ <Icon name="close" size={18} />
248
+ </button>
249
+ </header>
250
+
251
+ <div style={{ flex: 1, overflow: 'auto', padding: 12 }}>
252
+ {empty ? (
253
+ <div
254
+ data-testid="cs-history-panel-empty"
255
+ style={{ textAlign: 'center', opacity: 0.75, padding: '24px 8px' }}
256
+ >
257
+ <Icon name="history" size={40} style={{ opacity: 0.4 }} />
258
+ <div style={{ fontWeight: 600, marginTop: 8 }}>No activity yet</div>
259
+ <div style={{ fontSize: 13, marginTop: 4 }}>
260
+ Edits you make this session show up here.
261
+ </div>
262
+ </div>
263
+ ) : (
264
+ <ol
265
+ role="list"
266
+ style={{
267
+ listStyle: 'none',
268
+ margin: 0,
269
+ padding: 0,
270
+ display: 'flex',
271
+ flexDirection: 'column',
272
+ gap: 2,
273
+ }}
274
+ >
275
+ {sorted.map((e) => (
276
+ <li
277
+ key={e.seq}
278
+ data-testid="cs-history-row"
279
+ style={{
280
+ display: 'flex',
281
+ alignItems: 'center',
282
+ gap: 10,
283
+ padding: '8px 8px',
284
+ borderRadius: 8,
285
+ }}
286
+ >
287
+ <span
288
+ aria-hidden
289
+ style={{
290
+ display: 'inline-flex',
291
+ alignItems: 'center',
292
+ justifyContent: 'center',
293
+ width: 28,
294
+ height: 28,
295
+ flex: '0 0 auto',
296
+ borderRadius: 6,
297
+ background: 'var(--cs-chrome-subtle-bg, #f4f5f8)',
298
+ opacity: 0.9,
299
+ }}
300
+ >
301
+ <Icon name={e.icon} size={16} />
302
+ </span>
303
+ <span style={{ flex: 1, minWidth: 0, fontSize: 13, fontWeight: 500 }}>
304
+ {e.label}
305
+ </span>
306
+ <time
307
+ dateTime={new Date(e.t).toISOString()}
308
+ style={{ opacity: 0.6, fontSize: 12, whiteSpace: 'nowrap' }}
309
+ >
310
+ {relativeTime(e.t, now)}
311
+ </time>
312
+ </li>
313
+ ))}
314
+ </ol>
315
+ )}
316
+ </div>
317
+ </div>
318
+ );
319
+ }
@@ -0,0 +1,185 @@
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
+ * InsertCellsDialog — the SDK chrome's built-in Insert Cells modal.
19
+ *
20
+ * Follows the DataValidationDialog / FormatCellsDialog pattern: reads the active
21
+ * A1 selection off the FUniver facade, offers the four Excel/Sheets shift
22
+ * choices, and applies via real, installed facade methods:
23
+ *
24
+ * - Shift cells right → `FRange.insertCells(Dimension.COLUMNS)`
25
+ * - Shift cells down → `FRange.insertCells(Dimension.ROWS)`
26
+ * - Entire row → `FWorksheet.insertRows(startRow, rowCount)`
27
+ * - Entire column → `FWorksheet.insertColumns(startColumn, colCount)`
28
+ *
29
+ * The shift-direction ↔ Dimension mapping is grounded in the `insertCells`
30
+ * doc-example in `@univerjs/sheets/lib/types/facade/f-range.d.ts` (line 1484):
31
+ * `insertCells(Dimension.COLUMNS)` pushes existing data to the RIGHT, and
32
+ * `insertCells(Dimension.ROWS)` pushes it DOWN. `Dimension` is verified in
33
+ * `@univerjs/core/lib/types/types/enum/dimension.d.ts` (COLUMNS=0, ROWS=1).
34
+ * `FWorksheet.insertRows` / `insertColumns` are verified in
35
+ * `@univerjs/sheets/lib/types/facade/f-worksheet.d.ts` (lines 355 / 679), and
36
+ * the range extents come from `FRange.getRow/getColumn/getLastRow/getLastColumn`
37
+ * (f-range.d.ts lines 115/141/128/154).
38
+ *
39
+ * Mounted by `<DialogHost>` when `openDialog('insert-cells')` is called and no
40
+ * host override is registered.
41
+ */
42
+
43
+ import { useMemo, useState, type CSSProperties } from 'react';
44
+ import { Dimension } from '@univerjs/core';
45
+ import type { DialogComponentProps } from './extensions';
46
+ import type { CasualSheetsAPI } from '../sheets/api';
47
+ import { Dialog } from './Dialog';
48
+ import { DIALOG_BTN_PRIMARY_STYLE, DIALOG_BTN_SECONDARY_STYLE } from './dialog-styles';
49
+
50
+ /** The four shift choices, matching Excel / Google Sheets "Insert cells". */
51
+ type ShiftChoice = 'right' | 'down' | 'row' | 'column';
52
+
53
+ const SHIFT_OPTIONS: Array<{ value: ShiftChoice; label: string }> = [
54
+ { value: 'right', label: 'Shift cells right' },
55
+ { value: 'down', label: 'Shift cells down' },
56
+ { value: 'row', label: 'Entire row' },
57
+ { value: 'column', label: 'Entire column' },
58
+ ];
59
+
60
+ /** The active FRange, or null when there is no selection. */
61
+ function activeRange(api: CasualSheetsAPI) {
62
+ return api.univer.getActiveWorkbook()?.getActiveSheet()?.getActiveRange() ?? null;
63
+ }
64
+
65
+ /**
66
+ * Insert cells at the active selection with the chosen shift. Returns false when
67
+ * there is no active range (nothing to insert against).
68
+ */
69
+ function applyInsert(api: CasualSheetsAPI, shift: ShiftChoice): boolean {
70
+ const range = activeRange(api);
71
+ if (!range) return false;
72
+
73
+ switch (shift) {
74
+ case 'right':
75
+ // COLUMNS dimension pushes existing data to the right (f-range.d.ts:1484).
76
+ range.insertCells(Dimension.COLUMNS);
77
+ return true;
78
+ case 'down':
79
+ // ROWS dimension pushes existing data down.
80
+ range.insertCells(Dimension.ROWS);
81
+ return true;
82
+ case 'row': {
83
+ const sheet = api.univer.getActiveWorkbook()?.getActiveSheet();
84
+ if (!sheet) return false;
85
+ const startRow = range.getRow();
86
+ const rowCount = range.getLastRow() - startRow + 1;
87
+ sheet.insertRows(startRow, rowCount);
88
+ return true;
89
+ }
90
+ case 'column': {
91
+ const sheet = api.univer.getActiveWorkbook()?.getActiveSheet();
92
+ if (!sheet) return false;
93
+ const startColumn = range.getColumn();
94
+ const colCount = range.getLastColumn() - startColumn + 1;
95
+ sheet.insertColumns(startColumn, colCount);
96
+ return true;
97
+ }
98
+ }
99
+ }
100
+
101
+ const RANGE_NOTE_STYLE: CSSProperties = {
102
+ fontSize: 12,
103
+ color: 'var(--cs-chrome-muted, #605e5c)',
104
+ marginBottom: 14,
105
+ };
106
+
107
+ const RADIO_STYLE: CSSProperties = {
108
+ display: 'flex',
109
+ alignItems: 'center',
110
+ gap: 8,
111
+ marginBottom: 10,
112
+ cursor: 'pointer',
113
+ };
114
+
115
+ export function InsertCellsDialog({ api, onClose }: DialogComponentProps) {
116
+ const [shift, setShift] = useState<ShiftChoice>('down');
117
+
118
+ // Read the selection once for the header hint. `getA1Notation` off the live
119
+ // FRange (verified in @univerjs/sheets/facade f-range.d.ts) gives the
120
+ // user-facing A1 label, e.g. "A1:B2".
121
+ const rangeLabel = useMemo(() => {
122
+ const fRange = activeRange(api) as unknown as { getA1Notation?: () => string } | null;
123
+ return fRange?.getA1Notation?.() ?? null;
124
+ }, [api]);
125
+
126
+ const hasSelection = activeRange(api) !== null;
127
+
128
+ const apply = () => {
129
+ if (applyInsert(api, shift)) onClose();
130
+ };
131
+
132
+ return (
133
+ <Dialog
134
+ title="Insert cells"
135
+ onClose={onClose}
136
+ width={380}
137
+ data-testid="cs-insert-cells-dialog"
138
+ footer={
139
+ <>
140
+ <button type="button" style={DIALOG_BTN_SECONDARY_STYLE} onClick={onClose}>
141
+ Cancel
142
+ </button>
143
+ <button
144
+ type="button"
145
+ style={DIALOG_BTN_PRIMARY_STYLE}
146
+ data-testid="cs-insert-cells-apply"
147
+ disabled={!hasSelection}
148
+ onClick={apply}
149
+ >
150
+ Insert
151
+ </button>
152
+ </>
153
+ }
154
+ >
155
+ {hasSelection ? (
156
+ <div style={RANGE_NOTE_STYLE} data-testid="cs-insert-cells-range">
157
+ Insert at <strong>{rangeLabel ?? 'the current selection'}</strong>
158
+ </div>
159
+ ) : (
160
+ <div style={RANGE_NOTE_STYLE} data-testid="cs-insert-cells-no-selection">
161
+ Select one or more cells first, then reopen this dialog.
162
+ </div>
163
+ )}
164
+
165
+ <div role="radiogroup" aria-label="Shift direction">
166
+ {SHIFT_OPTIONS.map((opt) => (
167
+ <label
168
+ key={opt.value}
169
+ style={RADIO_STYLE}
170
+ data-testid={`cs-insert-cells-shift-${opt.value}-label`}
171
+ >
172
+ <input
173
+ type="radio"
174
+ name="cs-insert-cells-shift"
175
+ data-testid={`cs-insert-cells-shift-${opt.value}`}
176
+ checked={shift === opt.value}
177
+ onChange={() => setShift(opt.value)}
178
+ />
179
+ <span>{opt.label}</span>
180
+ </label>
181
+ ))}
182
+ </div>
183
+ </Dialog>
184
+ );
185
+ }