@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,344 @@
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
+ * InsertSparklineDialog — the SDK chrome's built-in "Insert sparkline" modal.
19
+ *
20
+ * Excel/Sheets-style in-cell sparklines: pick a type (line / column / win-loss),
21
+ * a data range (the values to plot), and a location cell (where the mini-chart
22
+ * renders). Follows the FormatCells/DataValidation exemplars: reads the active
23
+ * A1 selection off the FUniver facade to seed the fields, gathers the form, and
24
+ * applies through the SDK API.
25
+ *
26
+ * FACADE NOTE — no first-class Univer sparkline facade is installed. Unlike
27
+ * data-validation (`@univerjs/sheets-data-validation/facade`), there is no
28
+ * `@univerjs/sheets-sparkline` package in `node_modules/@univerjs/*`, so there is
29
+ * no `univerAPI.newSparkline()` builder or `FRange.setSparkline(...)` to call.
30
+ * Sparklines in this workbook are a Casual-Sheets feature persisted on
31
+ * `IWorkbookData.resources['__casual_sheets_sparklines__']` — the resource the
32
+ * app's `SparklineLayer` renders from and that round-trips through xlsx/collab
33
+ * (see apps/web/src/sparklines/{types,resources}.ts). The dialog therefore wires
34
+ * the REAL op the feature owns: it merges a new `SparklineModel` into that
35
+ * resource on the live snapshot via `api.getContent()` and re-mounts it via
36
+ * `api.setContent()` (the canonical cross-editor content accessors on
37
+ * `CasualSheetsAPI`). The resource name + model shape are duplicated locally so
38
+ * the SDK stays decoupled from the app package.
39
+ *
40
+ * Mounted by `<DialogHost>` when `openDialog('insert-sparkline')` is called and
41
+ * no host override is registered.
42
+ */
43
+
44
+ import { useMemo, useState, type CSSProperties } from 'react';
45
+ import type { IWorkbookData } from '@univerjs/core';
46
+ import type { DialogComponentProps } from './extensions';
47
+ import type { CasualSheetsAPI } from '../sheets/api';
48
+ import { Dialog } from './Dialog';
49
+ import {
50
+ DIALOG_BTN_PRIMARY_STYLE,
51
+ DIALOG_BTN_SECONDARY_STYLE,
52
+ DIALOG_FIELD_STYLE,
53
+ DIALOG_INPUT_STYLE,
54
+ DIALOG_LABEL_STYLE,
55
+ } from './dialog-styles';
56
+
57
+ /** Excel-canonical sparkline families. Mirrors apps/web sparklines `SparklineType`. */
58
+ type SparklineType = 'line' | 'column' | 'win-loss';
59
+
60
+ /** A single source rectangle in row/column indices. */
61
+ interface SourceRange {
62
+ startRow: number;
63
+ endRow: number;
64
+ startColumn: number;
65
+ endColumn: number;
66
+ }
67
+
68
+ /**
69
+ * Sparkline model persisted on the workbook resource. Structurally identical to
70
+ * the app's `SparklineModel` (apps/web/src/sparklines/types.ts) — duplicated here
71
+ * so the SDK doesn't depend on the app package. `SparklineLayer` reads this shape.
72
+ */
73
+ interface SparklineModel {
74
+ id: string;
75
+ type: SparklineType;
76
+ unitId: string;
77
+ sheetId: string;
78
+ source: SourceRange;
79
+ anchor: { row: number; col: number };
80
+ }
81
+
82
+ /** Resource key + payload envelope — mirrors `SPARKLINES_RESOURCE_NAME` /
83
+ * `SparklinesResourceV1` in apps/web/src/sparklines/types.ts. */
84
+ const SPARKLINES_RESOURCE_NAME = '__casual_sheets_sparklines__';
85
+ interface SparklinesResourceV1 {
86
+ v: 1;
87
+ sparklines: SparklineModel[];
88
+ }
89
+
90
+ const TYPE_OPTIONS: Array<{ value: SparklineType; label: string }> = [
91
+ { value: 'line', label: 'Line' },
92
+ { value: 'column', label: 'Column' },
93
+ { value: 'win-loss', label: 'Win / Loss' },
94
+ ];
95
+
96
+ interface DialogState {
97
+ type: SparklineType;
98
+ /** Data range in A1, e.g. "A1:F1". */
99
+ sourceA1: string;
100
+ /** Location cell in A1, e.g. "G1". */
101
+ anchorA1: string;
102
+ }
103
+
104
+ /** The active FRange, or null when there is no selection. */
105
+ function activeRange(api: CasualSheetsAPI) {
106
+ return api.univer.getActiveWorkbook()?.getActiveSheet()?.getActiveRange() ?? null;
107
+ }
108
+
109
+ /** Convert column letters (A, B, …, AA) to a 0-based index. */
110
+ function colLettersToIndex(letters: string): number {
111
+ let n = 0;
112
+ for (let i = 0; i < letters.length; i += 1) {
113
+ n = n * 26 + (letters.charCodeAt(i) - 64);
114
+ }
115
+ return n - 1;
116
+ }
117
+
118
+ /** Parse an A1 range ("A1:F1" or a single "A1") into row/column indices. */
119
+ function parseRange(s: string): SourceRange | null {
120
+ const m = /^\$?([A-Z]+)\$?(\d+)(?::\$?([A-Z]+)\$?(\d+))?$/.exec(s.trim().toUpperCase());
121
+ if (!m) return null;
122
+ const c1 = colLettersToIndex(m[1]);
123
+ const r1 = parseInt(m[2], 10) - 1;
124
+ const c2 = m[3] ? colLettersToIndex(m[3]) : c1;
125
+ const r2 = m[4] ? parseInt(m[4], 10) - 1 : r1;
126
+ return {
127
+ startRow: Math.min(r1, r2),
128
+ endRow: Math.max(r1, r2),
129
+ startColumn: Math.min(c1, c2),
130
+ endColumn: Math.max(c1, c2),
131
+ };
132
+ }
133
+
134
+ /** Parse a single A1 cell ("G1") into row/col indices. */
135
+ function parseSingleCell(s: string): { row: number; col: number } | null {
136
+ const m = /^\$?([A-Z]+)\$?(\d+)$/.exec(s.trim().toUpperCase());
137
+ if (!m) return null;
138
+ return { col: colLettersToIndex(m[1]), row: parseInt(m[2], 10) - 1 };
139
+ }
140
+
141
+ /**
142
+ * Merge a new sparkline into the workbook's sparklines resource and re-mount the
143
+ * snapshot. Returns false when there is no active workbook.
144
+ *
145
+ * This is the REAL persistence path for sparklines in this workbook (there is no
146
+ * Univer sparkline facade — see the file header). `getContent()` yields the live
147
+ * `IWorkbookData` including `resources`; we splice our model into the
148
+ * `__casual_sheets_sparklines__` entry and hand it back through `setContent()`,
149
+ * exactly the shape `SparklineLayer` renders and xlsx/collab round-trip.
150
+ */
151
+ function insertSparkline(api: CasualSheetsAPI, model: SparklineModel): boolean {
152
+ const data = api.getContent();
153
+ if (!data) return false;
154
+
155
+ const resources = data.resources ? [...data.resources] : [];
156
+ const idx = resources.findIndex((r) => r.name === SPARKLINES_RESOURCE_NAME);
157
+
158
+ let existing: SparklineModel[] = [];
159
+ if (idx >= 0 && resources[idx]?.data) {
160
+ try {
161
+ const parsed = JSON.parse(resources[idx].data) as Partial<SparklinesResourceV1>;
162
+ if (parsed?.v === 1 && Array.isArray(parsed.sparklines)) existing = parsed.sparklines;
163
+ } catch {
164
+ existing = [];
165
+ }
166
+ }
167
+
168
+ const payload: SparklinesResourceV1 = { v: 1, sparklines: [...existing, model] };
169
+ const entry = { name: SPARKLINES_RESOURCE_NAME, data: JSON.stringify(payload) };
170
+ if (idx >= 0) resources[idx] = entry;
171
+ else resources.push(entry);
172
+
173
+ const next: IWorkbookData = { ...data, resources };
174
+ api.setContent(next);
175
+ return true;
176
+ }
177
+
178
+ /** Read the active workbook's unitId + active sheetId for the model. */
179
+ function activeIds(api: CasualSheetsAPI): { unitId: string; sheetId: string } | null {
180
+ const wb = api.univer.getActiveWorkbook();
181
+ const sheet = wb?.getActiveSheet();
182
+ if (!wb || !sheet) return null;
183
+ return { unitId: wb.getId(), sheetId: sheet.getSheetId() };
184
+ }
185
+
186
+ const RANGE_NOTE_STYLE: CSSProperties = {
187
+ fontSize: 12,
188
+ color: 'var(--cs-chrome-muted, #605e5c)',
189
+ marginBottom: 12,
190
+ };
191
+
192
+ const RADIO_ROW_STYLE: CSSProperties = {
193
+ display: 'flex',
194
+ gap: 16,
195
+ marginTop: 2,
196
+ };
197
+
198
+ const RADIO_OPT_STYLE: CSSProperties = {
199
+ display: 'flex',
200
+ alignItems: 'center',
201
+ gap: 6,
202
+ cursor: 'pointer',
203
+ };
204
+
205
+ const ERROR_STYLE: CSSProperties = {
206
+ fontSize: 12,
207
+ color: 'var(--cs-chrome-danger-fg, #b91c1c)',
208
+ marginTop: 4,
209
+ };
210
+
211
+ export function InsertSparklineDialog({ api, onClose }: DialogComponentProps) {
212
+ // Seed the data range from the current selection (its A1 label), leaving the
213
+ // location cell for the user to pick — mirrors Excel's Insert Sparkline flow.
214
+ const selectedA1 = useMemo(() => {
215
+ const fRange = activeRange(api) as unknown as { getA1Notation?: () => string } | null;
216
+ return fRange?.getA1Notation?.() ?? '';
217
+ }, [api]);
218
+
219
+ const [state, setState] = useState<DialogState>({
220
+ type: 'line',
221
+ sourceA1: selectedA1,
222
+ anchorA1: '',
223
+ });
224
+ const [error, setError] = useState<string | null>(null);
225
+
226
+ const hasWorkbook = api.univer.getActiveWorkbook() != null;
227
+
228
+ const update = <K extends keyof DialogState>(key: K, value: DialogState[K]) => {
229
+ setState((prev) => ({ ...prev, [key]: value }));
230
+ setError(null);
231
+ };
232
+
233
+ const apply = () => {
234
+ const source = parseRange(state.sourceA1);
235
+ if (!source) {
236
+ setError('Data range must be a range like A1:F1.');
237
+ return;
238
+ }
239
+ const anchor = parseSingleCell(state.anchorA1);
240
+ if (!anchor) {
241
+ setError('Location must be a single cell like G1.');
242
+ return;
243
+ }
244
+ const ids = activeIds(api);
245
+ if (!ids) {
246
+ setError('No active sheet.');
247
+ return;
248
+ }
249
+
250
+ const model: SparklineModel = {
251
+ id: `spark-${Math.random().toString(36).slice(2, 10)}`,
252
+ type: state.type,
253
+ unitId: ids.unitId,
254
+ sheetId: ids.sheetId,
255
+ source,
256
+ anchor,
257
+ };
258
+ if (insertSparkline(api, model)) onClose();
259
+ else setError('Could not read the active workbook.');
260
+ };
261
+
262
+ return (
263
+ <Dialog
264
+ title="Insert sparkline"
265
+ onClose={onClose}
266
+ width={420}
267
+ data-testid="cs-insert-sparkline-dialog"
268
+ footer={
269
+ <>
270
+ <button type="button" style={DIALOG_BTN_SECONDARY_STYLE} onClick={onClose}>
271
+ Cancel
272
+ </button>
273
+ <button
274
+ type="button"
275
+ style={DIALOG_BTN_PRIMARY_STYLE}
276
+ data-testid="cs-insert-sparkline-apply"
277
+ disabled={!hasWorkbook}
278
+ onClick={apply}
279
+ >
280
+ Insert
281
+ </button>
282
+ </>
283
+ }
284
+ >
285
+ <div style={RANGE_NOTE_STYLE}>
286
+ Draws an in-cell mini-chart in the location cell from the values in the data range.
287
+ </div>
288
+
289
+ <label style={DIALOG_FIELD_STYLE}>
290
+ <span style={DIALOG_LABEL_STYLE}>Type</span>
291
+ <div
292
+ style={RADIO_ROW_STYLE}
293
+ role="radiogroup"
294
+ aria-label="Sparkline type"
295
+ data-testid="cs-insert-sparkline-type"
296
+ >
297
+ {TYPE_OPTIONS.map((opt) => (
298
+ <label key={opt.value} style={RADIO_OPT_STYLE}>
299
+ <input
300
+ type="radio"
301
+ name="cs-sparkline-type"
302
+ value={opt.value}
303
+ data-testid={`cs-insert-sparkline-type-${opt.value}`}
304
+ checked={state.type === opt.value}
305
+ onChange={() => update('type', opt.value)}
306
+ />
307
+ <span>{opt.label}</span>
308
+ </label>
309
+ ))}
310
+ </div>
311
+ </label>
312
+
313
+ <label style={DIALOG_FIELD_STYLE}>
314
+ <span style={DIALOG_LABEL_STYLE}>Data range</span>
315
+ <input
316
+ style={DIALOG_INPUT_STYLE}
317
+ data-testid="cs-insert-sparkline-source"
318
+ value={state.sourceA1}
319
+ placeholder="A1:F1"
320
+ spellCheck={false}
321
+ onChange={(e) => update('sourceA1', e.target.value.toUpperCase())}
322
+ />
323
+ </label>
324
+
325
+ <label style={DIALOG_FIELD_STYLE}>
326
+ <span style={DIALOG_LABEL_STYLE}>Location</span>
327
+ <input
328
+ style={DIALOG_INPUT_STYLE}
329
+ data-testid="cs-insert-sparkline-anchor"
330
+ value={state.anchorA1}
331
+ placeholder="G1"
332
+ spellCheck={false}
333
+ onChange={(e) => update('anchorA1', e.target.value.toUpperCase())}
334
+ />
335
+ </label>
336
+
337
+ {error && (
338
+ <div style={ERROR_STYLE} data-testid="cs-insert-sparkline-error">
339
+ {error}
340
+ </div>
341
+ )}
342
+ </Dialog>
343
+ );
344
+ }