@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,476 @@
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
+ import type { FUniver } from '@univerjs/core/facade';
18
+ import {
19
+ PALETTES,
20
+ mergeFormat,
21
+ type ChartModel,
22
+ type ChartType,
23
+ type ResolvedChartFormat,
24
+ } from './types';
25
+ import type { EChartsOption } from './echarts-init';
26
+
27
+ /**
28
+ * Read cells from the chart's source range and turn them into an
29
+ * ECharts option. Convention (mirrors Excel's default chart-from-
30
+ * selection):
31
+ *
32
+ * - Row 0 of the source range = header row → series names.
33
+ * - Column 0 = category axis labels (x-axis for column / area / line,
34
+ * y-axis for horizontal bar, dimension for pie).
35
+ * - Remaining cells = numeric values, one series per column.
36
+ *
37
+ * Non-numeric cells coerce to `null` so the chart shows a gap instead
38
+ * of NaN. If the source range collapses to one row / one column we
39
+ * fall back to a "No data" placeholder so the overlay still paints
40
+ * rather than crashing.
41
+ *
42
+ * Formatting (title visibility, legend position, axis titles,
43
+ * gridlines, data labels, colour palette) is applied from
44
+ * `mergeFormat(model)` — defaults match Excel's first-render.
45
+ */
46
+ export function buildEChartsOption(api: FUniver, model: ChartModel): EChartsOption | null {
47
+ const wb = api.getActiveWorkbook();
48
+ if (!wb) return null;
49
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
50
+ const sheets = wb.getSheets() as any[];
51
+ const ws = sheets.find((s) => s.getSheetId?.() === model.sheetId);
52
+ if (!ws) return null;
53
+
54
+ const { startRow, endRow, startColumn, endColumn } = model.source;
55
+ const rows = endRow - startRow + 1;
56
+ const cols = endColumn - startColumn + 1;
57
+ if (rows < 2 || cols < 2) {
58
+ return { title: { text: 'No data', left: 'center', top: 'center' } };
59
+ }
60
+
61
+ const headers: string[] = [];
62
+ for (let c = 1; c < cols; c++) {
63
+ const v = ws.getRange(startRow, startColumn + c).getValue();
64
+ headers.push(v == null ? `Series ${c}` : String(v));
65
+ }
66
+ const categories: string[] = [];
67
+ for (let r = 1; r < rows; r++) {
68
+ const v = ws.getRange(startRow + r, startColumn).getValue();
69
+ categories.push(v == null ? '' : String(v));
70
+ }
71
+ const seriesData: Array<Array<number | null>> = headers.map((_, sIdx) => {
72
+ const data: Array<number | null> = [];
73
+ for (let r = 1; r < rows; r++) {
74
+ const v = ws.getRange(startRow + r, startColumn + 1 + sIdx).getValue();
75
+ const n = typeof v === 'number' ? v : Number(v);
76
+ data.push(Number.isFinite(n) ? n : null);
77
+ }
78
+ return data;
79
+ });
80
+
81
+ const format = mergeFormat(model);
82
+ // If every category parses as a date, build the axis as a time axis
83
+ // — ECharts gets nicer auto-formatted tick labels (Jan / Feb / Mar /
84
+ // 2024) than the literal category strings. Pie/scatter/doughnut
85
+ // ignore this (no category axis); buildOptionForType branches on it.
86
+ const dates = detectDateCategories(categories);
87
+ return buildOptionForType(
88
+ model.type,
89
+ headers,
90
+ categories,
91
+ seriesData,
92
+ model.title,
93
+ format,
94
+ dates,
95
+ );
96
+ }
97
+
98
+ /**
99
+ * Returns a parallel array of ms timestamps if every category cell
100
+ * looks like a date, otherwise null. Heuristic: parses with
101
+ * `Date.parse` and accepts only results in the 1900–2100 range so we
102
+ * don't false-positive on raw numbers like `2024` (which parses as
103
+ * "year 2024-01-01") for what's actually a numeric category.
104
+ */
105
+ function detectDateCategories(categories: string[]): number[] | null {
106
+ if (categories.length === 0) return null;
107
+ const out: number[] = [];
108
+ const min = Date.UTC(1900, 0, 1);
109
+ const max = Date.UTC(2100, 0, 1);
110
+ // Require at least one slash, dash, or letter so a column of bare
111
+ // integers (e.g. `2024`, `2025`) doesn't get interpreted as years.
112
+ const dateLike = /[-/]|\b(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)\b/i;
113
+ for (const c of categories) {
114
+ if (!c) return null;
115
+ if (!dateLike.test(c)) return null;
116
+ const t = Date.parse(c);
117
+ if (!Number.isFinite(t) || t < min || t > max) return null;
118
+ out.push(t);
119
+ }
120
+ return out;
121
+ }
122
+
123
+ function buildOptionForType(
124
+ type: ChartType,
125
+ headers: string[],
126
+ categories: string[],
127
+ rawSeries: Array<Array<number | null>>,
128
+ title: string | undefined,
129
+ format: ResolvedChartFormat,
130
+ dateCategories: number[] | null = null,
131
+ ): EChartsOption {
132
+ const titleNode =
133
+ title && format.showTitle ? { text: title, left: 'center' as const } : undefined;
134
+ const colors = PALETTES[format.palette];
135
+ const legendNode = legendOption(format);
136
+ const showAxes = format.legend !== 'none';
137
+ void showAxes;
138
+
139
+ if (type === 'pie' || type === 'doughnut') {
140
+ const pieData = categories.map((label, i) => ({
141
+ name: label,
142
+ value: rawSeries[0]?.[i] ?? 0,
143
+ }));
144
+ return {
145
+ color: colors,
146
+ title: titleNode,
147
+ tooltip: { trigger: 'item' },
148
+ legend: legendNode,
149
+ series: [
150
+ {
151
+ type: 'pie',
152
+ radius: type === 'doughnut' ? ['40%', '70%'] : '60%',
153
+ center: ['50%', titleNode ? '52%' : '48%'],
154
+ data: pieData,
155
+ label: format.dataLabels ? { formatter: '{b}: {c}' } : { formatter: '{b}' },
156
+ },
157
+ ],
158
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
159
+ } as any;
160
+ }
161
+
162
+ if (type === 'scatter') {
163
+ const xs = rawSeries[0] ?? [];
164
+ const series = rawSeries.slice(1).map((ys, i) => ({
165
+ name: headers[i + 1] ?? headers[0],
166
+ type: 'scatter' as const,
167
+ data: xs.map((x, idx) => [x, ys[idx]]).filter(([a, b]) => a != null && b != null),
168
+ label: dataLabelConfig(format),
169
+ }));
170
+ return {
171
+ color: colors,
172
+ title: titleNode,
173
+ tooltip: { trigger: 'item' },
174
+ legend: legendNode,
175
+ grid: chartGrid(format, titleNode != null),
176
+ xAxis: {
177
+ type: 'value',
178
+ name: format.xAxisTitle ?? headers[0] ?? '',
179
+ nameLocation: 'middle',
180
+ nameGap: 24,
181
+ splitLine: { show: format.gridlines },
182
+ },
183
+ yAxis: {
184
+ type: 'value',
185
+ name: format.yAxisTitle ?? '',
186
+ nameLocation: 'middle',
187
+ nameGap: 36,
188
+ splitLine: { show: format.gridlines },
189
+ },
190
+ series:
191
+ series.length > 0
192
+ ? series
193
+ : [
194
+ {
195
+ name: headers[0] ?? 'Series',
196
+ type: 'scatter' as const,
197
+ data: xs.map((x, idx) => [idx, x]).filter(([, b]) => b != null),
198
+ label: dataLabelConfig(format),
199
+ },
200
+ ],
201
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
202
+ } as any;
203
+ }
204
+
205
+ const isHorizontalBar = type === 'bar' || type === 'bar-stacked' || type === 'bar-stacked-100';
206
+ const is100 = type === 'column-stacked-100' || type === 'bar-stacked-100';
207
+ // Combo + dual-axis only make sense on a plain (non-100%, non-
208
+ // horizontal) value-vs-category chart: column / line / area. A 100%-
209
+ // stacked chart already pins both series to a shared 0–100% scale,
210
+ // and horizontal bars swap the axes so a "secondary value axis on
211
+ // the right" no longer reads as Excel does it. Gate the features
212
+ // here so the rest of the branch can assume vertical, absolute axes.
213
+ const allowComboAndDualAxis = !is100 && !isHorizontalBar;
214
+ // Per-series secondary-axis flags, restricted to series that actually
215
+ // exist in this chart. Empty unless the feature applies.
216
+ const secondarySeries = allowComboAndDualAxis
217
+ ? headers.filter((name) => format.secondaryAxis?.[name])
218
+ : [];
219
+ const hasSecondaryAxis = secondarySeries.length > 0;
220
+ const isStacked =
221
+ type === 'column-stacked' ||
222
+ type === 'column-stacked-100' ||
223
+ type === 'bar-stacked' ||
224
+ type === 'bar-stacked-100' ||
225
+ type === 'line-stacked' ||
226
+ type === 'area-stacked';
227
+ const isLine = type === 'line' || type === 'line-stacked';
228
+ const isArea = type === 'area' || type === 'area-stacked';
229
+ const echartsType: 'bar' | 'line' = isLine || isArea ? 'line' : 'bar';
230
+
231
+ const sumPerCat = is100
232
+ ? categories.map((_, i) => {
233
+ let s = 0;
234
+ for (const ys of rawSeries) {
235
+ const v = ys[i];
236
+ if (typeof v === 'number') s += v;
237
+ }
238
+ return s === 0 ? 1 : s;
239
+ })
240
+ : null;
241
+
242
+ // Time axis only kicks in for horizontal-time charts (column / line /
243
+ // area). Horizontal bars keep the categorical axis to avoid weird
244
+ // sideways time scrolling.
245
+ const useTimeAxis = dateCategories != null && !isHorizontalBar;
246
+
247
+ const series = headers.map((name, sIdx) => {
248
+ const raw = rawSeries[sIdx] ?? [];
249
+ const dataRaw =
250
+ is100 && sumPerCat
251
+ ? raw.map((v, i) => (typeof v === 'number' ? (v / sumPerCat[i]) * 100 : null))
252
+ : raw;
253
+ // ECharts' time axis wants `[timestamp, value]` pairs. The
254
+ // category axis takes plain values aligned with the axis labels.
255
+ const data =
256
+ useTimeAxis && dateCategories
257
+ ? (dataRaw as Array<number | null>).map((v, i) => [dateCategories[i], v])
258
+ : dataRaw;
259
+ const trendlineMark = format.trendline
260
+ ? buildTrendlineMark(dataRaw as Array<number | null>)
261
+ : undefined;
262
+ // Per-series colour override: if the user picked a specific
263
+ // colour for this series in the Format Chart dialog, it wins over
264
+ // the palette's default. Stored on `format.seriesColors[name]`.
265
+ const overrideColor = format.seriesColors?.[name];
266
+ // Combo: a per-series render-kind override turns this single series
267
+ // into a bar or line regardless of the chart's base type. Only
268
+ // honoured on the column / line / area families (see
269
+ // `allowComboAndDualAxis`). Area's fill is preserved only when the
270
+ // series stays a line; an explicit `bar` override drops the fill.
271
+ const seriesKind = allowComboAndDualAxis ? format.seriesTypes?.[name] : undefined;
272
+ const resolvedType: 'bar' | 'line' = seriesKind ?? echartsType;
273
+ const seriesIsLine = resolvedType === 'line';
274
+ const seriesIsArea = isArea && seriesIsLine && !seriesKind;
275
+ // Dual axis: route this series to yAxisIndex 1 (the secondary,
276
+ // right-hand value axis) when flagged. `yAxis` becomes a two-entry
277
+ // array below; primary series keep the default index 0.
278
+ const onSecondary = allowComboAndDualAxis && Boolean(format.secondaryAxis?.[name]);
279
+ return {
280
+ name,
281
+ type: resolvedType,
282
+ data,
283
+ ...(hasSecondaryAxis ? { yAxisIndex: onSecondary ? 1 : 0 } : {}),
284
+ ...(isStacked && !seriesKind ? { stack: 'all' as const } : {}),
285
+ ...(seriesIsArea ? { areaStyle: {} } : {}),
286
+ ...(seriesIsLine ? { smooth: false, symbol: 'circle' as const, symbolSize: 4 } : {}),
287
+ ...(trendlineMark ? { markLine: trendlineMark } : {}),
288
+ ...(overrideColor
289
+ ? { itemStyle: { color: overrideColor }, lineStyle: { color: overrideColor } }
290
+ : {}),
291
+ label: dataLabelConfig(format, isHorizontalBar, seriesIsLine),
292
+ };
293
+ });
294
+
295
+ const categoryAxis = useTimeAxis
296
+ ? {
297
+ type: 'time' as const,
298
+ name: isHorizontalBar ? (format.yAxisTitle ?? '') : (format.xAxisTitle ?? ''),
299
+ nameLocation: 'middle' as const,
300
+ nameGap: 24,
301
+ }
302
+ : {
303
+ type: 'category' as const,
304
+ data: categories,
305
+ name: isHorizontalBar ? (format.yAxisTitle ?? '') : (format.xAxisTitle ?? ''),
306
+ nameLocation: 'middle' as const,
307
+ nameGap: 24,
308
+ };
309
+ const valueAxis: Record<string, unknown> = {
310
+ type: 'value' as const,
311
+ name: isHorizontalBar ? (format.xAxisTitle ?? '') : (format.yAxisTitle ?? ''),
312
+ nameLocation: 'middle',
313
+ nameGap: 36,
314
+ splitLine: { show: format.gridlines },
315
+ };
316
+ if (is100) {
317
+ valueAxis.max = 100;
318
+ valueAxis.axisLabel = { formatter: '{value}%' };
319
+ }
320
+
321
+ // Dual axis: build a second value axis aligned to the right. Its name
322
+ // defaults to the secondary series' name(s) so the reader can tell
323
+ // which line/bars it scales. `gridlines` is left off the secondary
324
+ // axis to avoid two overlapping splitLine grids fighting each other.
325
+ const secondaryValueAxis: Record<string, unknown> | null = hasSecondaryAxis
326
+ ? {
327
+ type: 'value' as const,
328
+ name: secondarySeries.join(' / '),
329
+ nameLocation: 'middle',
330
+ nameGap: 40,
331
+ position: 'right',
332
+ splitLine: { show: false },
333
+ }
334
+ : null;
335
+
336
+ // The value axis lives on Y for vertical charts. When a secondary
337
+ // axis is requested we emit `yAxis: [primary, secondary]` and the
338
+ // series above carry `yAxisIndex`. Horizontal bars + 100%-stacked
339
+ // never reach here with `hasSecondaryAxis` (gated by
340
+ // `allowComboAndDualAxis`), so the single-axis path stays unchanged
341
+ // for them.
342
+ const yAxisNode =
343
+ !isHorizontalBar && secondaryValueAxis
344
+ ? [valueAxis, secondaryValueAxis]
345
+ : isHorizontalBar
346
+ ? categoryAxis
347
+ : valueAxis;
348
+
349
+ return {
350
+ color: colors,
351
+ title: titleNode,
352
+ tooltip: {
353
+ trigger: 'axis',
354
+ ...(is100 ? { valueFormatter: (v: unknown) => `${Math.round(Number(v))}%` } : {}),
355
+ },
356
+ legend: legendNode,
357
+ grid: chartGrid(format, titleNode != null, hasSecondaryAxis),
358
+ xAxis: isHorizontalBar ? valueAxis : categoryAxis,
359
+ yAxis: yAxisNode,
360
+ series,
361
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
362
+ } as any;
363
+ }
364
+
365
+ function legendOption(format: ResolvedChartFormat): Record<string, unknown> | undefined {
366
+ if (format.legend === 'none') return undefined;
367
+ const pos: Record<string, unknown> = { type: 'scroll' };
368
+ switch (format.legend) {
369
+ case 'top':
370
+ pos.top = 0;
371
+ break;
372
+ case 'bottom':
373
+ pos.bottom = 0;
374
+ break;
375
+ case 'left':
376
+ pos.left = 0;
377
+ pos.orient = 'vertical';
378
+ break;
379
+ case 'right':
380
+ pos.right = 0;
381
+ pos.orient = 'vertical';
382
+ break;
383
+ }
384
+ return pos;
385
+ }
386
+
387
+ function chartGrid(
388
+ format: ResolvedChartFormat,
389
+ hasTitle: boolean,
390
+ hasSecondaryAxis = false,
391
+ ): Record<string, unknown> {
392
+ // Make room for legend / title / axis-name labels by padding the
393
+ // plot area. Without this the value axis name gets clipped by the
394
+ // legend at the bottom.
395
+ const grid: Record<string, unknown> = {
396
+ left: 56,
397
+ right: hasSecondaryAxis ? 56 : 24,
398
+ top: hasTitle ? 40 : 16,
399
+ bottom: 56,
400
+ containLabel: true,
401
+ };
402
+ switch (format.legend) {
403
+ case 'top':
404
+ grid.top = hasTitle ? 60 : 32;
405
+ break;
406
+ case 'left':
407
+ grid.left = 96;
408
+ break;
409
+ case 'right':
410
+ // A right-side legend AND a secondary axis both compete for the
411
+ // right margin; widen further so neither clips the other.
412
+ grid.right = hasSecondaryAxis ? 128 : 96;
413
+ break;
414
+ case 'none':
415
+ grid.bottom = 32;
416
+ break;
417
+ }
418
+ return grid;
419
+ }
420
+
421
+ function dataLabelConfig(
422
+ format: ResolvedChartFormat,
423
+ isHorizontalBar?: boolean,
424
+ isLineOrArea?: boolean,
425
+ ): Record<string, unknown> {
426
+ if (!format.dataLabels) return { show: false };
427
+ if (isLineOrArea) return { show: true, position: 'top' };
428
+ if (isHorizontalBar) return { show: true, position: 'right' };
429
+ return { show: true, position: 'top' };
430
+ }
431
+
432
+ /**
433
+ * Linear regression trendline. Computes the best-fit line via simple
434
+ * ordinary-least-squares on the series data points, then encodes it
435
+ * as an ECharts `markLine` from (x_min, y_pred(x_min)) to (x_max,
436
+ * y_pred(x_max)). Returns `undefined` if fewer than two valid
437
+ * numeric points exist (a single point has no slope).
438
+ */
439
+ function buildTrendlineMark(data: Array<number | null>): Record<string, unknown> | undefined {
440
+ const points: Array<{ x: number; y: number }> = [];
441
+ for (let i = 0; i < data.length; i += 1) {
442
+ const v = data[i];
443
+ if (typeof v === 'number' && !Number.isNaN(v)) points.push({ x: i, y: v });
444
+ }
445
+ if (points.length < 2) return undefined;
446
+ const n = points.length;
447
+ let sumX = 0;
448
+ let sumY = 0;
449
+ let sumXY = 0;
450
+ let sumXX = 0;
451
+ for (const p of points) {
452
+ sumX += p.x;
453
+ sumY += p.y;
454
+ sumXY += p.x * p.y;
455
+ sumXX += p.x * p.x;
456
+ }
457
+ const denom = n * sumXX - sumX * sumX;
458
+ if (denom === 0) return undefined;
459
+ const slope = (n * sumXY - sumX * sumY) / denom;
460
+ const intercept = (sumY - slope * sumX) / n;
461
+ const xMin = points[0].x;
462
+ const xMax = points[points.length - 1].x;
463
+ const yAtMin = slope * xMin + intercept;
464
+ const yAtMax = slope * xMax + intercept;
465
+ return {
466
+ silent: true,
467
+ symbol: 'none',
468
+ lineStyle: { type: 'dashed', width: 2, opacity: 0.75 },
469
+ data: [
470
+ [
471
+ { xAxis: xMin, yAxis: yAtMin },
472
+ { xAxis: xMax, yAxis: yAtMax },
473
+ ],
474
+ ],
475
+ };
476
+ }
@@ -0,0 +1,205 @@
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
+ import {
18
+ createContext,
19
+ useCallback,
20
+ useContext,
21
+ useEffect,
22
+ useMemo,
23
+ useRef,
24
+ useState,
25
+ } from 'react';
26
+ import type { ReactNode } from 'react';
27
+ import type { CasualSheetsAPI } from '../sheets/api';
28
+ import { readChartsFromSnapshot, writeChartsIntoSnapshot } from './resources';
29
+ import type { ChartModel } from './types';
30
+
31
+ /**
32
+ * Chart store mirrored into `IWorkbookData.resources['__casual_sheets_charts__']`
33
+ * at save time and re-hydrated when the active workbook changes. The store
34
+ * keeps charts keyed by id (`ChartModel.id`); removal by id, updates by
35
+ * replace — every change produces a new model object so React effect deps
36
+ * work for downstream consumers (ChartLayer, ChartOverlay).
37
+ *
38
+ * SDK port: the app read/wrote the store through the hidden xlsx pre-pass +
39
+ * `useWorkbook()` revision tracking. Here the store is loaded on mount and
40
+ * persisted on every local change through the SDK's `api.getContent()` /
41
+ * `api.setContent()` (which serialize/replace the active `IWorkbookData`).
42
+ * `api` is also re-exported via `useCharts()` so downstream chart components
43
+ * reach the FUniver facade through `api.univer` instead of a React hook.
44
+ */
45
+ type ChartsCtxValue = {
46
+ /** The SDK editor handle — chart components reach the FUniver facade via
47
+ * `api.univer` (never a React `useUniverAPI` hook, which doesn't exist in
48
+ * the SDK). */
49
+ api: CasualSheetsAPI;
50
+ charts: ChartModel[];
51
+ /** Id of the chart with the selection frame + handles drawn. At most
52
+ * one chart is selected at a time (Excel single-select; multi-select
53
+ * via Ctrl+click is not implemented yet). */
54
+ selectedId: string | null;
55
+ insert: (chart: ChartModel) => void;
56
+ remove: (id: string) => void;
57
+ update: (id: string, patch: Partial<ChartModel>) => void;
58
+ select: (id: string | null) => void;
59
+ /** Replace the entire chart list. Used by CollabDriver to apply
60
+ * remote chart-state updates from the Yjs sync map. App code should
61
+ * prefer `insert / remove / update` — `__replaceAll` bypasses the
62
+ * collab broadcast tag, so consecutive calls from outside the
63
+ * collab driver can clobber each other. */
64
+ __replaceAll: (next: ChartModel[], opts?: { fromCollab?: boolean }) => void;
65
+ /** Subscribe to LOCAL chart-list changes (insert / remove / update,
66
+ * excluding remote echoes from `__replaceAll({ fromCollab: true })`).
67
+ * CollabDriver uses this to push our edits into the Yjs map without
68
+ * echoing them back. Returns an unsubscribe. */
69
+ __subscribeLocal: (cb: (charts: ChartModel[]) => void) => () => void;
70
+ };
71
+
72
+ export const ChartsContext = createContext<ChartsCtxValue | null>(null);
73
+
74
+ export function useCharts(): ChartsCtxValue {
75
+ const ctx = useContext(ChartsContext);
76
+ if (!ctx) throw new Error('useCharts must be used inside <ChartsProvider>');
77
+ return ctx;
78
+ }
79
+
80
+ export function ChartsProvider({ api, children }: { api: CasualSheetsAPI; children: ReactNode }) {
81
+ const [charts, setCharts] = useState<ChartModel[]>(() =>
82
+ readChartsFromSnapshot(api.getContent() ?? undefined),
83
+ );
84
+ const [selectedId, setSelectedId] = useState<string | null>(null);
85
+ // Local change subscribers (used by CollabDriver to push to Yjs).
86
+ // Stored on a ref so the subscribe API is stable across renders.
87
+ const subsRef = useRef<Set<(c: ChartModel[]) => void>>(new Set());
88
+ const notifyLocal = useCallback((next: ChartModel[]) => {
89
+ for (const cb of subsRef.current) cb(next);
90
+ }, []);
91
+
92
+ // Persist the chart list into the active workbook snapshot on every local
93
+ // change. Mirrors the app's write-into-resources pre-pass, but driven
94
+ // through the SDK content accessors. Guarded by `persistingRef` so the
95
+ // `setContent` round-trip we trigger here doesn't bounce back through the
96
+ // `change` re-hydrate below as a phantom remote update.
97
+ const persistingRef = useRef(false);
98
+ const persist = useCallback(
99
+ (next: ChartModel[]) => {
100
+ const snap = api.getContent();
101
+ if (!snap) return;
102
+ writeChartsIntoSnapshot(snap, next);
103
+ persistingRef.current = true;
104
+ try {
105
+ api.setContent(snap);
106
+ } finally {
107
+ persistingRef.current = false;
108
+ }
109
+ },
110
+ [api],
111
+ );
112
+
113
+ // Re-hydrate on external content swaps (Open / New / collab remote-snapshot).
114
+ // The app keyed this off `useWorkbook().meta.revision`; the SDK surfaces the
115
+ // same signal as the `change` event carrying a fresh `IWorkbookData`. Skip
116
+ // the echo from our own `persist()` setContent so a local edit doesn't wipe
117
+ // the selection / re-read what we just wrote.
118
+ useEffect(() => {
119
+ const off = api.on('change', (snapshot) => {
120
+ if (persistingRef.current) return;
121
+ setCharts(readChartsFromSnapshot(snapshot));
122
+ setSelectedId(null);
123
+ });
124
+ return off;
125
+ }, [api]);
126
+
127
+ const insert = useCallback(
128
+ (chart: ChartModel) => {
129
+ setCharts((prev) => {
130
+ const next = [...prev, chart];
131
+ notifyLocal(next);
132
+ persist(next);
133
+ return next;
134
+ });
135
+ },
136
+ [notifyLocal, persist],
137
+ );
138
+
139
+ const remove = useCallback(
140
+ (id: string) => {
141
+ setCharts((prev) => {
142
+ const next = prev.filter((c) => c.id !== id);
143
+ notifyLocal(next);
144
+ persist(next);
145
+ return next;
146
+ });
147
+ setSelectedId((cur) => (cur === id ? null : cur));
148
+ },
149
+ [notifyLocal, persist],
150
+ );
151
+
152
+ const update = useCallback(
153
+ (id: string, patch: Partial<ChartModel>) => {
154
+ setCharts((prev) => {
155
+ const next = prev.map((c) => (c.id === id ? { ...c, ...patch } : c));
156
+ notifyLocal(next);
157
+ persist(next);
158
+ return next;
159
+ });
160
+ },
161
+ [notifyLocal, persist],
162
+ );
163
+
164
+ const select = useCallback((id: string | null) => {
165
+ setSelectedId(id);
166
+ }, []);
167
+
168
+ const __replaceAll = useCallback(
169
+ (next: ChartModel[], opts?: { fromCollab?: boolean }) => {
170
+ setCharts(next);
171
+ // Drop the selection if its chart no longer exists in the new list
172
+ // (a peer deleted it).
173
+ setSelectedId((cur) => (cur && next.some((c) => c.id === cur) ? cur : null));
174
+ if (!opts?.fromCollab) {
175
+ notifyLocal(next);
176
+ persist(next);
177
+ }
178
+ },
179
+ [notifyLocal, persist],
180
+ );
181
+
182
+ const __subscribeLocal = useCallback((cb: (charts: ChartModel[]) => void) => {
183
+ subsRef.current.add(cb);
184
+ return () => {
185
+ subsRef.current.delete(cb);
186
+ };
187
+ }, []);
188
+
189
+ const value = useMemo<ChartsCtxValue>(
190
+ () => ({
191
+ api,
192
+ charts,
193
+ selectedId,
194
+ insert,
195
+ remove,
196
+ update,
197
+ select,
198
+ __replaceAll,
199
+ __subscribeLocal,
200
+ }),
201
+ [api, charts, selectedId, insert, remove, update, select, __replaceAll, __subscribeLocal],
202
+ );
203
+
204
+ return <ChartsContext.Provider value={value}>{children}</ChartsContext.Provider>;
205
+ }