@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.
- package/dist/chrome.cjs +3339 -244
- package/dist/chrome.cjs.map +1 -1
- package/dist/chrome.js +3298 -203
- 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/NameManagerDialog.tsx +378 -0
- package/src/chrome/PasteSpecialDialog.tsx +286 -0
- package/src/chrome/dialog-context.tsx +24 -0
|
@@ -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
|
+
}
|