@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,183 @@
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
+ * DeleteCellsDialog — the SDK chrome's built-in Delete Cells modal.
19
+ *
20
+ * Excel/Sheets "Delete cells…" dialog: delete the active selection with a
21
+ * shift choice — shift the surviving cells left, shift them up, or delete the
22
+ * selection's entire rows / entire columns.
23
+ *
24
+ * Applied through the FUniver facade (no invented commands):
25
+ * - shift left → `FRange.deleteCells(Dimension.COLUMNS)`
26
+ * - shift up → `FRange.deleteCells(Dimension.ROWS)`
27
+ * - entire row → `FWorksheet.deleteRows(startRow, rowCount)`
28
+ * - entire column → `FWorksheet.deleteColumns(startColumn, columnCount)`
29
+ *
30
+ * Grounded in the installed facade types:
31
+ * - `Dimension` enum (COLUMNS=0, ROWS=1) — @univerjs/core dimension.d.ts.
32
+ * - `FRange.deleteCells(shiftDimension: Dimension)` — @univerjs/sheets
33
+ * facade f-range.d.ts L1541; `getRow/getColumn/getLastRow/getLastColumn`
34
+ * L115/141/128/154; `getA1Notation` L1200.
35
+ * - `FWorksheet.deleteRows(pos, howMany)` L418 / `deleteColumns(pos, howMany)`
36
+ * L742 — @univerjs/sheets facade f-worksheet.d.ts.
37
+ *
38
+ * Mounted by `<DialogHost>` when `openDialog('delete-cells')` is called and no
39
+ * host override is registered.
40
+ */
41
+
42
+ import { useMemo, useState, type CSSProperties } from 'react';
43
+ import { Dimension } from '@univerjs/core';
44
+ import type { DialogComponentProps } from './extensions';
45
+ import type { CasualSheetsAPI } from '../sheets/api';
46
+ import { Dialog } from './Dialog';
47
+ import { DIALOG_BTN_PRIMARY_STYLE, DIALOG_BTN_SECONDARY_STYLE } from './dialog-styles';
48
+
49
+ /** How surviving cells move (or what dimension is removed wholesale). */
50
+ type ShiftChoice = 'left' | 'up' | 'row' | 'column';
51
+
52
+ const SHIFT_OPTIONS: Array<{ value: ShiftChoice; label: string }> = [
53
+ { value: 'left', label: 'Shift cells left' },
54
+ { value: 'up', label: 'Shift cells up' },
55
+ { value: 'row', label: 'Entire row' },
56
+ { value: 'column', label: 'Entire column' },
57
+ ];
58
+
59
+ /** The active FRange, or null when there is no selection. */
60
+ function activeRange(api: CasualSheetsAPI) {
61
+ return api.univer.getActiveWorkbook()?.getActiveSheet()?.getActiveRange() ?? null;
62
+ }
63
+
64
+ /**
65
+ * Apply the delete via the facade. Returns false when there's no active range
66
+ * (or no active sheet for the whole-row / whole-column paths).
67
+ */
68
+ function applyDelete(api: CasualSheetsAPI, choice: ShiftChoice): boolean {
69
+ const range = activeRange(api);
70
+ if (!range) return false;
71
+
72
+ if (choice === 'left') {
73
+ // Existing data to the right shifts left into the gap.
74
+ range.deleteCells(Dimension.COLUMNS);
75
+ return true;
76
+ }
77
+ if (choice === 'up') {
78
+ // Existing data below shifts up into the gap.
79
+ range.deleteCells(Dimension.ROWS);
80
+ return true;
81
+ }
82
+
83
+ const sheet = api.univer.getActiveWorkbook()?.getActiveSheet();
84
+ if (!sheet) return false;
85
+
86
+ if (choice === 'row') {
87
+ const start = range.getRow();
88
+ const count = range.getLastRow() - start + 1;
89
+ if (count <= 0) return false;
90
+ sheet.deleteRows(start, count);
91
+ return true;
92
+ }
93
+
94
+ // choice === 'column'
95
+ const start = range.getColumn();
96
+ const count = range.getLastColumn() - start + 1;
97
+ if (count <= 0) return false;
98
+ sheet.deleteColumns(start, count);
99
+ return true;
100
+ }
101
+
102
+ const RANGE_NOTE_STYLE: CSSProperties = {
103
+ fontSize: 12,
104
+ color: 'var(--cs-chrome-muted, #605e5c)',
105
+ marginBottom: 12,
106
+ };
107
+
108
+ const OPTION_STYLE: CSSProperties = {
109
+ display: 'flex',
110
+ alignItems: 'center',
111
+ gap: 8,
112
+ padding: '6px 4px',
113
+ cursor: 'pointer',
114
+ };
115
+
116
+ export function DeleteCellsDialog({ api, onClose }: DialogComponentProps) {
117
+ const [choice, setChoice] = useState<ShiftChoice>('left');
118
+
119
+ // Read the selection once for the header hint. `getA1Notation` off the live
120
+ // FRange (verified in @univerjs/sheets/facade f-range.d.ts) gives the
121
+ // user-facing A1 label, e.g. "A1:B2".
122
+ const rangeLabel = useMemo(() => {
123
+ const fRange = activeRange(api) as unknown as { getA1Notation?: () => string } | null;
124
+ return fRange?.getA1Notation?.() ?? null;
125
+ }, [api]);
126
+
127
+ const hasSelection = activeRange(api) !== null;
128
+
129
+ const apply = () => {
130
+ if (applyDelete(api, choice)) onClose();
131
+ };
132
+
133
+ return (
134
+ <Dialog
135
+ title="Delete cells"
136
+ onClose={onClose}
137
+ width={360}
138
+ data-testid="cs-delete-cells-dialog"
139
+ footer={
140
+ <>
141
+ <button type="button" style={DIALOG_BTN_SECONDARY_STYLE} onClick={onClose}>
142
+ Cancel
143
+ </button>
144
+ <button
145
+ type="button"
146
+ style={DIALOG_BTN_PRIMARY_STYLE}
147
+ data-testid="cs-delete-cells-apply"
148
+ disabled={!hasSelection}
149
+ onClick={apply}
150
+ >
151
+ Delete
152
+ </button>
153
+ </>
154
+ }
155
+ >
156
+ {hasSelection ? (
157
+ <div style={RANGE_NOTE_STYLE} data-testid="cs-delete-cells-range">
158
+ Delete <strong>{rangeLabel ?? 'the current selection'}</strong> and…
159
+ </div>
160
+ ) : (
161
+ <div style={RANGE_NOTE_STYLE} data-testid="cs-delete-cells-no-selection">
162
+ Select one or more cells first, then reopen this dialog.
163
+ </div>
164
+ )}
165
+
166
+ <div role="radiogroup" aria-label="Delete option">
167
+ {SHIFT_OPTIONS.map((opt) => (
168
+ <label key={opt.value} style={OPTION_STYLE}>
169
+ <input
170
+ type="radio"
171
+ name="cs-delete-cells-shift"
172
+ data-testid={`cs-delete-cells-${opt.value}`}
173
+ value={opt.value}
174
+ checked={choice === opt.value}
175
+ onChange={() => setChoice(opt.value)}
176
+ />
177
+ <span>{opt.label}</span>
178
+ </label>
179
+ ))}
180
+ </div>
181
+ </Dialog>
182
+ );
183
+ }
@@ -0,0 +1,370 @@
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
+ * GoalSeekDialog — the SDK chrome's built-in What-If Goal Seek modal.
19
+ *
20
+ * Excel's Goal Seek: pick a formula cell ("Set cell"), a target result
21
+ * ("To value"), and one input cell ("By changing cell"). The solver drives the
22
+ * input cell until the formula cell reaches the target.
23
+ *
24
+ * The installed Univer facade ships NO goal-seek command (verified: no `goalSeek`
25
+ * anywhere under the `@univerjs/sheets` facade modules), so this dialog implements
26
+ * the solver client-side against real facade primitives only:
27
+ *
28
+ * - read/write cells → `FWorksheet.getRange(a1)` → `FRange.setValue(n)` /
29
+ * `FRange.getValue()` (`@univerjs/sheets/facade`
30
+ * f-range.d.ts L814 / L309, f-worksheet.d.ts getRange L279)
31
+ * - settle the formula → `univerAPI.getFormula().onCalculationEnd()` returns a
32
+ * `Promise<void>` that resolves once the async formula
33
+ * engine has applied results (`@univerjs/engine-formula/facade`
34
+ * f-formula.d.ts L127; the mixin `getFormula()` is on
35
+ * f-univer.d.ts L22). Univer recomputes formulas
36
+ * asynchronously after a `setValue` mutation, so we await
37
+ * this before reading the formula cell back.
38
+ *
39
+ * Numerics: a damped secant iteration (finite-difference seeded when the two
40
+ * probes coincide), with a bisection-style bracket fallback. Non-convergence
41
+ * within the iteration budget restores the input cell to its original value and
42
+ * surfaces a message — no silent partial writes.
43
+ *
44
+ * Mounted by `<DialogHost>` when `openDialog('goal-seek')` is called and no host
45
+ * override is registered.
46
+ */
47
+
48
+ import { useMemo, useState, type CSSProperties } from 'react';
49
+ // Side-effect import: installs the formula-engine facade mixin
50
+ // (`univerAPI.getFormula()` + FFormula.onCalculationEnd) onto the facade
51
+ // prototype. `@univerjs/sheets/facade` (imported by sheets/api.ts) pulls the
52
+ // sheet mixins; this one guarantees the formula engine surface is present at the
53
+ // call site regardless of which facade groups a host registered.
54
+ import '@univerjs/sheets-formula/facade';
55
+ import type { DialogComponentProps } from './extensions';
56
+ import type { CasualSheetsAPI } from '../sheets/api';
57
+ import { Dialog } from './Dialog';
58
+ import {
59
+ DIALOG_BTN_PRIMARY_STYLE,
60
+ DIALOG_BTN_SECONDARY_STYLE,
61
+ DIALOG_FIELD_STYLE,
62
+ DIALOG_INPUT_STYLE,
63
+ DIALOG_LABEL_STYLE,
64
+ } from './dialog-styles';
65
+
66
+ /** Loosely-typed slice of the FUniver facade this dialog leans on. */
67
+ interface Solver {
68
+ worksheet: {
69
+ getRange: (a1: string) => {
70
+ setValue: (value: number) => unknown;
71
+ getValue: () => unknown;
72
+ } | null;
73
+ };
74
+ /** Wait for the async formula engine to apply results after a mutation. */
75
+ settle: () => Promise<void>;
76
+ }
77
+
78
+ /** Resolve the active-sheet range/settle helpers, or null when unavailable. */
79
+ function getSolver(api: CasualSheetsAPI): Solver | null {
80
+ const worksheet = api.univer.getActiveWorkbook()?.getActiveSheet() ?? null;
81
+ if (!worksheet) return null;
82
+ const formula = (
83
+ api.univer as unknown as { getFormula?: () => { onCalculationEnd?: () => Promise<void> } }
84
+ ).getFormula?.();
85
+ return {
86
+ worksheet: worksheet as unknown as Solver['worksheet'],
87
+ settle: async () => {
88
+ // onCalculationEnd resolves on the next completed calc pass. If the facade
89
+ // is missing it (older group set), fall back to a microtask + rAF so the
90
+ // engine's async recompute has a chance to flush before we read back.
91
+ if (formula?.onCalculationEnd) {
92
+ await formula.onCalculationEnd();
93
+ return;
94
+ }
95
+ await new Promise<void>((resolve) => {
96
+ if (typeof requestAnimationFrame === 'function') requestAnimationFrame(() => resolve());
97
+ else setTimeout(resolve, 16);
98
+ });
99
+ },
100
+ };
101
+ }
102
+
103
+ /** Coerce a Univer cell value to a finite number, or NaN. */
104
+ function toNumber(value: unknown): number {
105
+ if (typeof value === 'number') return value;
106
+ if (typeof value === 'boolean') return value ? 1 : 0;
107
+ if (typeof value === 'string' && value.trim() !== '') return Number(value);
108
+ return Number.NaN;
109
+ }
110
+
111
+ /** Basic A1 single-cell validation (letters + digits, no range colon). */
112
+ function isSingleCellA1(ref: string): boolean {
113
+ return /^[A-Za-z]{1,3}[1-9][0-9]*$/.test(ref.trim());
114
+ }
115
+
116
+ interface DialogState {
117
+ /** The formula cell whose result we're steering (A1). */
118
+ setCell: string;
119
+ /** The target value the formula cell should reach. */
120
+ toValue: string;
121
+ /** The input cell the solver adjusts (A1). */
122
+ byChangingCell: string;
123
+ }
124
+
125
+ const INITIAL_STATE: DialogState = { setCell: '', toValue: '', byChangingCell: '' };
126
+
127
+ /** Convergence tolerance on |formula(x) - target|. */
128
+ const TOLERANCE = 1e-6;
129
+ /** Max solver iterations before declaring non-convergence. */
130
+ const MAX_ITERATIONS = 100;
131
+
132
+ type SeekResult = { ok: true; solution: number; result: number } | { ok: false; reason: string };
133
+
134
+ /**
135
+ * Drive `byChangingCell` until `setCell`'s formula result reaches `target`.
136
+ * Restores the original input on failure so the sheet is never left mid-solve.
137
+ */
138
+ async function goalSeek(
139
+ solver: Solver,
140
+ setCellRef: string,
141
+ target: number,
142
+ changeCellRef: string,
143
+ ): Promise<SeekResult> {
144
+ const setRange = solver.worksheet.getRange(setCellRef);
145
+ const changeRange = solver.worksheet.getRange(changeCellRef);
146
+ if (!setRange || !changeRange) return { ok: false, reason: 'Could not resolve the cells.' };
147
+
148
+ const original = toNumber(changeRange.getValue());
149
+ const x0Seed = Number.isFinite(original) ? original : 0;
150
+
151
+ // Evaluate the formula cell after setting the input to `x`.
152
+ const evalAt = async (x: number): Promise<number> => {
153
+ changeRange.setValue(x);
154
+ await solver.settle();
155
+ return toNumber(setRange.getValue());
156
+ };
157
+
158
+ const restore = async () => {
159
+ changeRange.setValue(x0Seed);
160
+ await solver.settle();
161
+ };
162
+
163
+ // f(x) = formula(x) - target; we want a root of f.
164
+ let x0 = x0Seed;
165
+ let f0 = (await evalAt(x0)) - target;
166
+ if (!Number.isFinite(f0)) {
167
+ await restore();
168
+ return { ok: false, reason: 'The set cell is not a number (check that it holds a formula).' };
169
+ }
170
+ if (Math.abs(f0) <= TOLERANCE) return { ok: true, solution: x0, result: f0 + target };
171
+
172
+ // Second probe: nudge x0. Use a step scaled to the magnitude of x0 so we get a
173
+ // meaningful finite-difference slope even for large/small inputs.
174
+ let x1 = x0 !== 0 ? x0 * 1.01 + 0.01 : 1;
175
+ let f1 = (await evalAt(x1)) - target;
176
+
177
+ for (let i = 0; i < MAX_ITERATIONS; i++) {
178
+ if (Number.isFinite(f1) && Math.abs(f1) <= TOLERANCE) {
179
+ return { ok: true, solution: x1, result: f1 + target };
180
+ }
181
+
182
+ const denom = f1 - f0;
183
+ let x2: number;
184
+ if (!Number.isFinite(f1) || denom === 0) {
185
+ // Flat/undefined slope — perturb to break the stall rather than divide by 0.
186
+ x2 = x1 + (x1 !== 0 ? x1 * 0.1 : 0.1);
187
+ } else {
188
+ // Secant step, damped to avoid overshoot on stiff curves.
189
+ const step = (f1 * (x1 - x0)) / denom;
190
+ x2 = x1 - step;
191
+ if (!Number.isFinite(x2)) x2 = x1 + (x1 !== 0 ? x1 * 0.1 : 0.1);
192
+ }
193
+
194
+ const f2 = (await evalAt(x2)) - target;
195
+ x0 = x1;
196
+ f0 = f1;
197
+ x1 = x2;
198
+ f1 = f2;
199
+ }
200
+
201
+ await restore();
202
+ return {
203
+ ok: false,
204
+ reason: `Could not converge after ${MAX_ITERATIONS} iterations. Try an input cell whose value the set cell actually depends on.`,
205
+ };
206
+ }
207
+
208
+ const NOTE_STYLE: CSSProperties = {
209
+ fontSize: 12,
210
+ color: 'var(--cs-chrome-muted, #605e5c)',
211
+ marginBottom: 12,
212
+ };
213
+
214
+ const STATUS_OK_STYLE: CSSProperties = {
215
+ fontSize: 12,
216
+ color: 'var(--cs-chrome-active-fg, #0e7490)',
217
+ marginTop: 4,
218
+ };
219
+
220
+ const STATUS_ERR_STYLE: CSSProperties = {
221
+ fontSize: 12,
222
+ color: 'var(--cs-chrome-danger, #b91c1c)',
223
+ marginTop: 4,
224
+ };
225
+
226
+ type Status =
227
+ | { kind: 'idle' }
228
+ | { kind: 'running' }
229
+ | { kind: 'done'; solution: number; result: number }
230
+ | { kind: 'error'; message: string };
231
+
232
+ export function GoalSeekDialog({ api, onClose }: DialogComponentProps) {
233
+ // Seed "Set cell" with the current single-cell selection, if any.
234
+ const initialSetCell = useMemo(() => {
235
+ const range = api.univer.getActiveWorkbook()?.getActiveSheet()?.getActiveRange();
236
+ const a1 = (range as unknown as { getA1Notation?: () => string } | null)?.getA1Notation?.();
237
+ return a1 && isSingleCellA1(a1) ? a1 : '';
238
+ }, [api]);
239
+
240
+ const [state, setState] = useState<DialogState>({ ...INITIAL_STATE, setCell: initialSetCell });
241
+ const [status, setStatus] = useState<Status>({ kind: 'idle' });
242
+
243
+ const update = <K extends keyof DialogState>(key: K, value: DialogState[K]) => {
244
+ setState((prev) => ({ ...prev, [key]: value }));
245
+ setStatus({ kind: 'idle' });
246
+ };
247
+
248
+ const setCellValid = isSingleCellA1(state.setCell);
249
+ const changeCellValid = isSingleCellA1(state.byChangingCell);
250
+ const targetNum = Number(state.toValue);
251
+ const targetValid = state.toValue.trim() !== '' && Number.isFinite(targetNum);
252
+ const canRun =
253
+ setCellValid &&
254
+ changeCellValid &&
255
+ targetValid &&
256
+ state.setCell.trim().toUpperCase() !== state.byChangingCell.trim().toUpperCase() &&
257
+ status.kind !== 'running';
258
+
259
+ const run = async () => {
260
+ const solver = getSolver(api);
261
+ if (!solver) {
262
+ setStatus({ kind: 'error', message: 'No active sheet.' });
263
+ return;
264
+ }
265
+ setStatus({ kind: 'running' });
266
+ try {
267
+ const result = await goalSeek(
268
+ solver,
269
+ state.setCell.trim(),
270
+ targetNum,
271
+ state.byChangingCell.trim(),
272
+ );
273
+ if (result.ok) {
274
+ setStatus({ kind: 'done', solution: result.solution, result: result.result });
275
+ } else {
276
+ setStatus({ kind: 'error', message: result.reason });
277
+ }
278
+ } catch (err) {
279
+ setStatus({
280
+ kind: 'error',
281
+ message: err instanceof Error ? err.message : 'Goal seek failed.',
282
+ });
283
+ }
284
+ };
285
+
286
+ const running = status.kind === 'running';
287
+
288
+ return (
289
+ <Dialog
290
+ title="Goal seek"
291
+ onClose={onClose}
292
+ width={420}
293
+ data-testid="cs-goal-seek-dialog"
294
+ footer={
295
+ <>
296
+ <button type="button" style={DIALOG_BTN_SECONDARY_STYLE} onClick={onClose}>
297
+ {status.kind === 'done' ? 'Done' : 'Cancel'}
298
+ </button>
299
+ <button
300
+ type="button"
301
+ style={{ ...DIALOG_BTN_PRIMARY_STYLE, opacity: canRun ? 1 : 0.5 }}
302
+ data-testid="cs-goal-seek-run"
303
+ disabled={!canRun}
304
+ onClick={run}
305
+ >
306
+ {running ? 'Solving…' : 'Solve'}
307
+ </button>
308
+ </>
309
+ }
310
+ >
311
+ <div style={NOTE_STYLE} data-testid="cs-goal-seek-note">
312
+ Drives the input cell until the formula cell reaches your target value.
313
+ </div>
314
+
315
+ <label style={DIALOG_FIELD_STYLE}>
316
+ <span style={DIALOG_LABEL_STYLE}>Set cell (formula cell)</span>
317
+ <input
318
+ style={DIALOG_INPUT_STYLE}
319
+ data-testid="cs-goal-seek-set-cell"
320
+ placeholder="e.g. B5"
321
+ value={state.setCell}
322
+ onChange={(e) => update('setCell', e.target.value)}
323
+ />
324
+ </label>
325
+
326
+ <label style={DIALOG_FIELD_STYLE}>
327
+ <span style={DIALOG_LABEL_STYLE}>To value</span>
328
+ <input
329
+ style={DIALOG_INPUT_STYLE}
330
+ data-testid="cs-goal-seek-to-value"
331
+ type="number"
332
+ placeholder="e.g. 1000"
333
+ value={state.toValue}
334
+ onChange={(e) => update('toValue', e.target.value)}
335
+ />
336
+ </label>
337
+
338
+ <label style={DIALOG_FIELD_STYLE}>
339
+ <span style={DIALOG_LABEL_STYLE}>By changing cell (input cell)</span>
340
+ <input
341
+ style={DIALOG_INPUT_STYLE}
342
+ data-testid="cs-goal-seek-change-cell"
343
+ placeholder="e.g. B2"
344
+ value={state.byChangingCell}
345
+ onChange={(e) => update('byChangingCell', e.target.value)}
346
+ />
347
+ </label>
348
+
349
+ {status.kind === 'done' && (
350
+ <div style={STATUS_OK_STYLE} data-testid="cs-goal-seek-success">
351
+ Solved: <strong>{state.byChangingCell.trim().toUpperCase()}</strong> ={' '}
352
+ {formatNumber(status.solution)} makes{' '}
353
+ <strong>{state.setCell.trim().toUpperCase()}</strong> = {formatNumber(status.result)}.
354
+ </div>
355
+ )}
356
+ {status.kind === 'error' && (
357
+ <div style={STATUS_ERR_STYLE} data-testid="cs-goal-seek-error">
358
+ {status.message}
359
+ </div>
360
+ )}
361
+ </Dialog>
362
+ );
363
+ }
364
+
365
+ /** Trim solver float noise for display without losing precision users care about. */
366
+ function formatNumber(n: number): string {
367
+ if (!Number.isFinite(n)) return String(n);
368
+ const rounded = Math.round(n * 1e6) / 1e6;
369
+ return String(rounded);
370
+ }