@casualoffice/sheets 0.18.0 → 0.20.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 (61) hide show
  1. package/dist/{api-DJhuCjhn.d.cts → api-cyO4EA4_.d.cts} +1 -1
  2. package/dist/{api-DJhuCjhn.d.ts → api-cyO4EA4_.d.ts} +1 -1
  3. package/dist/{attachCollab-BmCRT4V_.d.ts → attachCollab-BT4fVzxP.d.ts} +1 -1
  4. package/dist/{attachCollab-DIUQCCrq.d.cts → attachCollab-DHjb5XYK.d.cts} +1 -1
  5. package/dist/chrome.cjs +60 -3
  6. package/dist/chrome.cjs.map +1 -1
  7. package/dist/chrome.d.cts +3 -3
  8. package/dist/chrome.d.ts +3 -3
  9. package/dist/chrome.js +60 -3
  10. package/dist/chrome.js.map +1 -1
  11. package/dist/collab.d.cts +2 -2
  12. package/dist/collab.d.ts +2 -2
  13. package/dist/embed/embed-runtime.js +205 -161
  14. package/dist/{extensions-DZnqTEN2.d.ts → extensions-Cni7mbEm.d.ts} +1 -1
  15. package/dist/{extensions-FFqdLpk_.d.cts → extensions-DMmPnIYB.d.cts} +1 -1
  16. package/dist/index.cjs +6424 -1559
  17. package/dist/index.cjs.map +1 -1
  18. package/dist/index.d.cts +3 -3
  19. package/dist/index.d.ts +3 -3
  20. package/dist/index.js +6409 -1530
  21. package/dist/index.js.map +1 -1
  22. package/dist/sheets.cjs +5426 -561
  23. package/dist/sheets.cjs.map +1 -1
  24. package/dist/sheets.d.cts +5 -5
  25. package/dist/sheets.d.ts +5 -5
  26. package/dist/sheets.js +5415 -536
  27. package/dist/sheets.js.map +1 -1
  28. package/package.json +8 -7
  29. package/src/charts/ChartContextMenu.tsx +264 -0
  30. package/src/charts/ChartLayer.tsx +333 -0
  31. package/src/charts/ChartOverlay.tsx +293 -0
  32. package/src/charts/ChartsPanel.tsx +232 -0
  33. package/src/charts/FormatChartDialog.tsx +419 -0
  34. package/src/charts/InsertChartDialog.tsx +478 -0
  35. package/src/charts/build-option.ts +476 -0
  36. package/src/charts/charts-context.tsx +205 -0
  37. package/src/charts/echarts-init.ts +58 -0
  38. package/src/charts/hit-test.ts +130 -0
  39. package/src/charts/insert-chart.ts +106 -0
  40. package/src/charts/naming.ts +38 -0
  41. package/src/charts/render-to-png.ts +117 -0
  42. package/src/charts/resources.ts +108 -0
  43. package/src/charts/types.ts +239 -0
  44. package/src/charts/univer-dom.ts +102 -0
  45. package/src/chrome/CommentsPanel.tsx +418 -0
  46. package/src/chrome/HistoryPanel.tsx +304 -0
  47. package/src/chrome/InsertPivotDialog.tsx +74 -2
  48. package/src/chrome/PanelHost.tsx +56 -0
  49. package/src/chrome/PanelRail.tsx +90 -0
  50. package/src/chrome/PivotFieldsPanel.tsx +1021 -0
  51. package/src/chrome/TablesPanel.tsx +283 -0
  52. package/src/chrome/Toolbar.tsx +7 -1
  53. package/src/chrome/panel-context.tsx +55 -0
  54. package/src/chrome/panel-registry.ts +48 -0
  55. package/src/chrome/panel-shell.tsx +151 -0
  56. package/src/pivots/apply.ts +139 -0
  57. package/src/pivots/compute.ts +604 -0
  58. package/src/pivots/fields-model.ts +267 -0
  59. package/src/pivots/types.ts +137 -0
  60. package/src/sheets/CasualSheets.tsx +61 -35
  61. package/src/sheets/api.ts +37 -2
@@ -0,0 +1,267 @@
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
+ * Pure, immutable model transforms for the PivotTable Fields pane.
19
+ *
20
+ * The pane mirrors Excel's "PivotTable Fields" panel: a source-field
21
+ * list plus four drop zones — Filters / Columns / Rows / Values. Every
22
+ * edit (assign a field to a zone, remove it, reorder, change a value's
23
+ * aggregation) is expressed here as a `PivotModel → PivotModel` step so
24
+ * the React panel never mutates the live model in place. (An earlier
25
+ * bug elsewhere came from aliasing a live array; these clone instead.)
26
+ *
27
+ * Axis rules, matching Excel:
28
+ * - Rows / Columns / Filters hold a given source column at most once.
29
+ * - A field on an axis is mutually exclusive across Rows/Columns/Filters
30
+ * (moving it to one strips it from the others).
31
+ * - Values may hold the same column more than once (Sum + Count of the
32
+ * same field), so Values entries are addressed by index, not column.
33
+ * - Placing a field on an axis does NOT remove it from Values, and
34
+ * vice-versa (e.g. Region in Rows + Count of Region in Values).
35
+ */
36
+
37
+ import type { DateGrouping, PivotAggregation, PivotModel, PivotValueField } from './types';
38
+
39
+ export type ZoneId = 'filters' | 'rows' | 'cols' | 'values';
40
+
41
+ export const ZONE_LABELS: Record<ZoneId, string> = {
42
+ filters: 'Filters',
43
+ cols: 'Columns',
44
+ rows: 'Rows',
45
+ values: 'Values',
46
+ };
47
+
48
+ /** Single-instance axes — a column appears at most once in each. */
49
+ const AXES: ZoneId[] = ['rows', 'cols', 'filters'];
50
+
51
+ /** Every source column placed somewhere, for the field-list checkmarks. */
52
+ export function placedColumns(model: PivotModel): Set<number> {
53
+ const s = new Set<number>();
54
+ for (const r of model.rows) s.add(r.column);
55
+ for (const c of model.cols) s.add(c.column);
56
+ for (const v of model.values) s.add(v.column);
57
+ for (const f of model.filters ?? []) s.add(f.column);
58
+ return s;
59
+ }
60
+
61
+ /** Shallow-clone the four zone arrays so callers can mutate the copies. */
62
+ function cloneZones(model: PivotModel): {
63
+ rows: PivotModel['rows'];
64
+ cols: PivotModel['cols'];
65
+ values: PivotValueField[];
66
+ filters: NonNullable<PivotModel['filters']>;
67
+ } {
68
+ return {
69
+ rows: model.rows.map((r) => ({ ...r })),
70
+ cols: model.cols.map((c) => ({ ...c })),
71
+ values: model.values.map((v) => ({ ...v })),
72
+ filters: (model.filters ?? []).map((f) => ({ ...f, allowedValues: [...f.allowedValues] })),
73
+ };
74
+ }
75
+
76
+ /** Drop a column from the single-instance axes (rows/cols/filters). */
77
+ function stripFromAxes(z: ReturnType<typeof cloneZones>, column: number): void {
78
+ z.rows = z.rows.filter((r) => r.column !== column);
79
+ z.cols = z.cols.filter((c) => c.column !== column);
80
+ z.filters = z.filters.filter((f) => f.column !== column);
81
+ }
82
+
83
+ /**
84
+ * Assign a source column to a zone.
85
+ *
86
+ * Rows/Columns/Filters: idempotent — moving the column to a new axis
87
+ * strips it from the other two. Values: appends a new entry (so a field
88
+ * can be aggregated more than once); `defaultAgg` lets the caller pick
89
+ * Sum for numeric columns and Count for text.
90
+ */
91
+ export function addFieldToZone(
92
+ model: PivotModel,
93
+ column: number,
94
+ zone: ZoneId,
95
+ opts?: { defaultAgg?: PivotAggregation; allowedValues?: string[] },
96
+ ): PivotModel {
97
+ const z = cloneZones(model);
98
+ if (zone === 'values') {
99
+ z.values.push({ column, agg: opts?.defaultAgg ?? 'sum', showAs: 'normal' });
100
+ return { ...model, rows: z.rows, cols: z.cols, values: z.values, filters: z.filters };
101
+ }
102
+ // Axis zones are mutually exclusive — pull the column off every axis first.
103
+ stripFromAxes(z, column);
104
+ if (zone === 'rows') z.rows.push({ column });
105
+ else if (zone === 'cols') z.cols.push({ column });
106
+ else z.filters.push({ column, allowedValues: opts?.allowedValues ?? [] });
107
+ return { ...model, rows: z.rows, cols: z.cols, values: z.values, filters: z.filters };
108
+ }
109
+
110
+ /** Remove the entry at `index` from a zone (Values by index; axes by index too). */
111
+ export function removeFieldFromZone(model: PivotModel, zone: ZoneId, index: number): PivotModel {
112
+ const z = cloneZones(model);
113
+ z[zone] = z[zone].filter((_, i) => i !== index) as never;
114
+ return { ...model, rows: z.rows, cols: z.cols, values: z.values, filters: z.filters };
115
+ }
116
+
117
+ /** Reorder within a zone (drag a chip up/down, or the order buttons). */
118
+ export function moveWithinZone(
119
+ model: PivotModel,
120
+ zone: ZoneId,
121
+ from: number,
122
+ to: number,
123
+ ): PivotModel {
124
+ const z = cloneZones(model);
125
+ const arr = z[zone] as unknown[];
126
+ if (from < 0 || from >= arr.length || to < 0 || to >= arr.length || from === to) return model;
127
+ const [moved] = arr.splice(from, 1);
128
+ arr.splice(to, 0, moved);
129
+ return { ...model, rows: z.rows, cols: z.cols, values: z.values, filters: z.filters };
130
+ }
131
+
132
+ /** Patch a Values entry (aggregation / Show-Values-As). */
133
+ export function updateValueField(
134
+ model: PivotModel,
135
+ index: number,
136
+ patch: Partial<Pick<PivotValueField, 'agg' | 'showAs'>>,
137
+ ): PivotModel {
138
+ const values = model.values.map((v, i) => (i === index ? { ...v, ...patch } : v));
139
+ return { ...model, values };
140
+ }
141
+
142
+ /** Set the date grouping on a Rows entry (Year/Quarter/Month/none). */
143
+ export function updateRowGrouping(
144
+ model: PivotModel,
145
+ index: number,
146
+ grouping: DateGrouping,
147
+ ): PivotModel {
148
+ const rows = model.rows.map((r, i) =>
149
+ i === index ? { ...r, grouping: grouping === 'none' ? undefined : grouping } : r,
150
+ );
151
+ return { ...model, rows };
152
+ }
153
+
154
+ /** True when the model still has at least one value field — the panel
155
+ * blocks removing the last one (a value-less pivot renders nothing). */
156
+ export function hasValues(model: PivotModel): boolean {
157
+ return model.values.length > 0;
158
+ }
159
+
160
+ /** Which axis (if any) currently holds a column — drives the field-list
161
+ * badge ("R" / "C" / "▽"). Values is reported separately since a column
162
+ * can be on an axis and in Values at once. */
163
+ export function axisOf(model: PivotModel, column: number): Exclude<ZoneId, 'values'> | null {
164
+ for (const ax of AXES) {
165
+ const arr = ax === 'filters' ? (model.filters ?? []) : model[ax];
166
+ if (arr.some((e) => e.column === column)) return ax as Exclude<ZoneId, 'values'>;
167
+ }
168
+ return null;
169
+ }
170
+
171
+ /**
172
+ * Check/uncheck a single value on a report filter (Filters zone). The
173
+ * allowedValues list is the set of values that pass; compute keeps only
174
+ * records whose value is in it (an empty list excludes everything, same
175
+ * as Excel showing nothing when you deselect all). `allValues` is the
176
+ * filter field's full distinct set — needed so the first uncheck can
177
+ * seed the list from "all selected" rather than an empty array, and so
178
+ * the result preserves the canonical (sorted) order.
179
+ */
180
+ export function toggleFilterValue(
181
+ model: PivotModel,
182
+ filterIndex: number,
183
+ value: string,
184
+ checked: boolean,
185
+ allValues: string[],
186
+ ): PivotModel {
187
+ const filters = (model.filters ?? []).map((f) => ({ ...f, allowedValues: [...f.allowedValues] }));
188
+ const f = filters[filterIndex];
189
+ if (!f) return model;
190
+ // A filter stored with the complete set (or an empty list, the slice-1
191
+ // "all pass" convention) starts from every value; toggling narrows it.
192
+ const current = new Set(f.allowedValues.length ? f.allowedValues : allValues);
193
+ if (checked) current.add(value);
194
+ else current.delete(value);
195
+ f.allowedValues = allValues.filter((v) => current.has(v));
196
+ return { ...model, filters };
197
+ }
198
+
199
+ /** Set a filter's allowed set wholesale (Select all / Clear). */
200
+ export function setFilterValues(
201
+ model: PivotModel,
202
+ filterIndex: number,
203
+ values: string[],
204
+ ): PivotModel {
205
+ const filters = (model.filters ?? []).map((f) => ({ ...f, allowedValues: [...f.allowedValues] }));
206
+ if (!filters[filterIndex]) return model;
207
+ filters[filterIndex].allowedValues = [...values];
208
+ return { ...model, filters };
209
+ }
210
+
211
+ /** Count of allowed values for a filter, treating an empty stored list as
212
+ * "all" (the slice-1 convention) so the chip can show "M of M". */
213
+ export function filterAllowedCount(
214
+ model: PivotModel,
215
+ filterIndex: number,
216
+ allCount: number,
217
+ ): number {
218
+ const f = (model.filters ?? [])[filterIndex];
219
+ if (!f) return allCount;
220
+ return f.allowedValues.length ? f.allowedValues.length : allCount;
221
+ }
222
+
223
+ /** What's being dragged in the Fields pane — a not-yet-placed source field
224
+ * from the field list, or an already-placed chip in a zone. */
225
+ export type DragPayload =
226
+ | { from: 'list'; column: number }
227
+ | { from: 'zone'; zone: ZoneId; index: number };
228
+
229
+ /** The source column held by a placed chip (Values address by index since a
230
+ * column can repeat there). */
231
+ function columnInZone(model: PivotModel, zone: ZoneId, index: number): number | null {
232
+ const arr =
233
+ zone === 'values' ? model.values : zone === 'filters' ? (model.filters ?? []) : model[zone];
234
+ return arr[index]?.column ?? null;
235
+ }
236
+
237
+ /**
238
+ * Resolve a drag-and-drop drop onto a zone into a model change.
239
+ *
240
+ * - From the field list → assign the column to the target zone (same as the
241
+ * click "+" menu).
242
+ * - From another zone → move the field (remove from source, add to target).
243
+ * Dropping onto the same zone is a no-op here — within-zone reorder is the
244
+ * chip's ▲▼ buttons (a remove+re-add would discard a Values entry's
245
+ * aggregation). Moving the last Values field out is also a no-op so the
246
+ * pivot never goes value-less (matches the remove-button guard).
247
+ *
248
+ * `optsFor(column)` supplies the per-column defaults (Sum vs Count for a new
249
+ * Values entry; the full distinct set for a new report filter) — kept as a
250
+ * callback so this stays pure + unit-testable without source access.
251
+ */
252
+ export function applyDrop(
253
+ model: PivotModel,
254
+ payload: DragPayload,
255
+ targetZone: ZoneId,
256
+ optsFor: (column: number) => { defaultAgg?: PivotAggregation; allowedValues?: string[] },
257
+ ): PivotModel {
258
+ if (payload.from === 'list') {
259
+ return addFieldToZone(model, payload.column, targetZone, optsFor(payload.column));
260
+ }
261
+ if (payload.zone === targetZone) return model;
262
+ const column = columnInZone(model, payload.zone, payload.index);
263
+ if (column == null) return model;
264
+ if (payload.zone === 'values' && model.values.length <= 1) return model;
265
+ const removed = removeFieldFromZone(model, payload.zone, payload.index);
266
+ return addFieldToZone(removed, column, targetZone, optsFor(column));
267
+ }
@@ -0,0 +1,137 @@
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
+ * Pivot table model. Excel's PivotCacheDefinition + PivotTableDefinition,
19
+ * compressed to what we need for v0.1. The model is small and
20
+ * JSON-serializable so it can live on `IWorkbookData.resources` the
21
+ * same way chart models do.
22
+ *
23
+ * Conventions (same as charts):
24
+ *
25
+ * - Source range row 0 = header row → field names.
26
+ * - Subsequent rows = records.
27
+ * - Field references are column indices within the source range
28
+ * (0-indexed). Names are looked up from the headers at render
29
+ * time so the model survives a column rename — there's no
30
+ * ambiguity until somebody changes the source range shape, at
31
+ * which point the field count may shift.
32
+ */
33
+
34
+ export type PivotAggregation = 'sum' | 'count' | 'average' | 'min' | 'max' | 'distinctCount';
35
+
36
+ export const PIVOT_AGG_LABELS: Record<PivotAggregation, string> = {
37
+ sum: 'Sum',
38
+ count: 'Count',
39
+ average: 'Average',
40
+ min: 'Min',
41
+ max: 'Max',
42
+ distinctCount: 'Distinct Count',
43
+ };
44
+
45
+ /** Pivot field reference — column index within the source range. */
46
+ /** Date grouping for a (date) row field — buckets records by the derived
47
+ * period instead of the raw date. 'none' keys by the raw value. */
48
+ export type DateGrouping = 'none' | 'year' | 'quarter' | 'month';
49
+
50
+ export const PIVOT_DATE_GROUP_LABELS: Record<DateGrouping, string> = {
51
+ none: 'No grouping',
52
+ year: 'Years',
53
+ quarter: 'Quarters',
54
+ month: 'Months',
55
+ };
56
+
57
+ /** Pivot field reference — column index within the source range, plus an
58
+ * optional date grouping applied when bucketing rows. */
59
+ export type PivotFieldRef = { column: number; grouping?: DateGrouping };
60
+
61
+ /** Pivot value field — a column to aggregate + the aggregation. */
62
+ /** How a value field is displayed (Excel's "Show Values As"). 'normal' = the
63
+ * raw aggregate; 'pctOfGrandTotal' = each cell as a % of the field's overall
64
+ * grand total; 'pctOfColumnTotal' = each cell as a % of its column's total
65
+ * (in a cross-tab; identical to grand total in the row-only layout). */
66
+ export type PivotShowAs = 'normal' | 'pctOfGrandTotal' | 'pctOfColumnTotal' | 'pctOfRowTotal';
67
+
68
+ export const PIVOT_SHOW_AS_LABELS: Record<PivotShowAs, string> = {
69
+ normal: 'Normal',
70
+ pctOfGrandTotal: '% of Grand Total',
71
+ pctOfColumnTotal: '% of Column Total',
72
+ pctOfRowTotal: '% of Row Total',
73
+ };
74
+
75
+ export type PivotValueField = {
76
+ column: number;
77
+ agg: PivotAggregation;
78
+ /** Display transform; absent/`'normal'` shows the raw aggregate. */
79
+ showAs?: PivotShowAs;
80
+ };
81
+
82
+ /** P1 — a filter field. Source records are kept only when the value
83
+ * in `column` is one of `allowedValues` (compared as strings). An
84
+ * empty `allowedValues` excludes everything; absent entry means no
85
+ * restriction. */
86
+ export type PivotFilter = {
87
+ column: number;
88
+ allowedValues: string[];
89
+ };
90
+
91
+ export type PivotModel = {
92
+ id: string;
93
+ /** Sheet the SOURCE data lives on. */
94
+ sourceSheetId: string;
95
+ /** Source data range — first row is headers, subsequent rows are
96
+ * records. */
97
+ source: { startRow: number; endRow: number; startColumn: number; endColumn: number };
98
+ /** Sheet the pivot OUTPUT lives on. Often the same as source, but
99
+ * Excel lets you target a different sheet. */
100
+ targetSheetId: string;
101
+ /** Top-left cell of the pivot output (0-indexed row + col). */
102
+ target: { row: number; column: number };
103
+ /** Row fields, applied left-to-right. P0 ships single-field; the
104
+ * array shape makes multi-field a P1 follow-up without a schema
105
+ * bump. */
106
+ rows: PivotFieldRef[];
107
+ /** Column fields. Empty in P0 — every value column collapses into
108
+ * one column. */
109
+ cols: PivotFieldRef[];
110
+ /** Value fields. Each one produces its own column in the output. */
111
+ values: PivotValueField[];
112
+ /** P1 — filter fields. Optional for backwards-compat; an absent or
113
+ * empty array means no filtering. */
114
+ filters?: PivotFilter[];
115
+ /** Last written output extent so `Refresh` can clear the previous
116
+ * output rectangle before writing the new one. Absent on freshly-
117
+ * loaded pivots from pre-P1 workbooks — refresh in that case just
118
+ * overwrites the new extent and leaves any residual rows. */
119
+ lastOutputExtent?: { rows: number; cols: number };
120
+ /** Display name. Auto-generated "PivotTable N" on insert; renameable
121
+ * via the Pivots panel. */
122
+ title?: string;
123
+ };
124
+
125
+ export function newPivotId(): string {
126
+ return `pt-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
127
+ }
128
+
129
+ /** Plugin-resource name we use when stashing pivot models in
130
+ * `IWorkbookData.resources`. Mirrors `CHARTS_RESOURCE_NAME` — survives
131
+ * xlsx via the hidden `__casual_sheets_resources__` sheet. */
132
+ export const PIVOTS_RESOURCE_NAME = '__casual_sheets_pivots__';
133
+
134
+ export type PivotsResourceV1 = {
135
+ v: 1;
136
+ pivots: PivotModel[];
137
+ };
@@ -106,6 +106,16 @@ import {
106
106
  import type { ChromeExtensions } from '../chrome/extensions';
107
107
  import type { DialogKind } from '../chrome/dialog-context';
108
108
  import { AiPanelSurface, type SheetsAiConfig } from '../ai/AiPanelSurface';
109
+ import { PanelProvider } from '../chrome/panel-context';
110
+ import { PanelRail } from '../chrome/PanelRail';
111
+ import { PanelHost } from '../chrome/PanelHost';
112
+ // Charts: the provider is echarts-free (resources/types only) so it imports
113
+ // eagerly; the overlay pulls echarts, so it's lazy — echarts becomes its own
114
+ // async chunk, loaded only when a chart actually renders.
115
+ import { ChartsProvider } from '../charts/charts-context';
116
+ const ChartLayer = lazy(() =>
117
+ import('../charts/ChartLayer').then((m) => ({ default: m.ChartLayer })),
118
+ );
109
119
  // Chrome is lazy-loaded from the `@casualoffice/sheets/chrome` subpath (NOT a
110
120
  // relative import — that would inline under this build's splitting:false). The
111
121
  // subpath is externalised in tsup, so the consumer's bundler code-splits it and
@@ -475,7 +485,7 @@ export function CasualSheets({
475
485
 
476
486
  univer.createUnit(UniverInstanceType.UNIVER_SHEET, initialData);
477
487
 
478
- const api = createCasualSheetsAPI(FUniver.newAPI(univer));
488
+ const api = createCasualSheetsAPI(FUniver.newAPI(univer), initialData.resources);
479
489
  const apiInternal = api as CasualSheetsAPIInternal;
480
490
  apiRef.current = api;
481
491
  // Bridge the declarative event props (doc 38 §3) to the unified emitter, so
@@ -713,47 +723,63 @@ export function CasualSheets({
713
723
  } as CSSProperties;
714
724
 
715
725
  return (
716
- <div
717
- className={className}
718
- data-testid={testId}
719
- data-theme={dark ? 'dark' : 'light'}
720
- onKeyDownCapture={onKeyDownCapture}
721
- style={{
722
- ...DEFAULT_STYLE,
723
- ...chromeVars,
724
- ...style,
725
- display: 'flex',
726
- flexDirection: 'column',
727
- }}
728
- >
729
- {/* Bars appear once their lazy chunk loads (a tick after first paint); the
726
+ <PanelProvider>
727
+ <div
728
+ className={className}
729
+ data-testid={testId}
730
+ data-theme={dark ? 'dark' : 'light'}
731
+ onKeyDownCapture={onKeyDownCapture}
732
+ style={{
733
+ ...DEFAULT_STYLE,
734
+ ...chromeVars,
735
+ ...style,
736
+ display: 'flex',
737
+ flexDirection: 'column',
738
+ }}
739
+ >
740
+ {/* Bars appear once their lazy chunk loads (a tick after first paint); the
730
741
  grid host is OUTSIDE Suspense so Univer mounts immediately. */}
731
- <Suspense fallback={null}>
732
- <ChromeTop
733
- api={chromeApi}
734
- features={features}
735
- onDialogRequest={onDialogRequest}
736
- hostOwnedDialogs={hostOwnedDialogs}
737
- extensions={extensions}
738
- />
739
- </Suspense>
740
- {hasAi ? (
741
- // Grid + AI pane share a flex row so the built-in chrome (top/bottom
742
- // bars) still spans the full width. Shape fixed at mount by `hasAi`.
742
+ <Suspense fallback={null}>
743
+ <ChromeTop
744
+ api={chromeApi}
745
+ features={features}
746
+ onDialogRequest={onDialogRequest}
747
+ hostOwnedDialogs={hostOwnedDialogs}
748
+ extensions={extensions}
749
+ />
750
+ </Suspense>
751
+ {/* Grid + side panels + rail share a flex row so the built-in chrome
752
+ (top/bottom bars) still spans the full width. The row shape is stable
753
+ (grid host never remounts); PanelHost/AI aside only add/remove
754
+ siblings, and the rail is always present. */}
743
755
  <div style={{ flex: '1 1 auto', minHeight: 0, display: 'flex', flexDirection: 'row' }}>
744
756
  <div
745
757
  ref={hostRef}
758
+ data-testid="univer-host"
746
759
  style={{ flex: '1 1 auto', minWidth: 0, minHeight: 0, position: 'relative' }}
747
760
  />
748
- <AiPanelSurface config={ai} api={aiApi} />
761
+ {/* Charts context + overlay live once the api is ready (it arrives
762
+ after Univer boots into the grid host above, so it can't gate the
763
+ host). ChartLayer portals onto the `univer-host` marker; the
764
+ Charts panel (inside PanelHost) reads the same context. */}
765
+ {chromeApi ? (
766
+ <ChartsProvider api={chromeApi}>
767
+ <Suspense fallback={null}>
768
+ <ChartLayer />
769
+ </Suspense>
770
+ <PanelHost api={chromeApi} extensions={extensions} />
771
+ </ChartsProvider>
772
+ ) : (
773
+ <PanelHost api={chromeApi} extensions={extensions} />
774
+ )}
775
+ {hasAi && <AiPanelSurface config={ai} api={aiApi} />}
776
+ <PanelRail extensions={extensions} />
749
777
  </div>
750
- ) : (
751
- <div ref={hostRef} style={{ flex: '1 1 auto', minHeight: 0, position: 'relative' }} />
752
- )}
753
- <Suspense fallback={null}>
754
- <ChromeBottom api={chromeApi} />
755
- </Suspense>
756
- </div>
778
+ <Suspense fallback={null}>
779
+ <ChromeBottom api={chromeApi} />
780
+ </Suspense>
781
+ </div>
782
+ </PanelProvider>
757
783
  );
758
784
  }
759
785
 
package/src/sheets/api.ts CHANGED
@@ -235,7 +235,33 @@ export interface CasualSheetsAPIInternal extends CasualSheetsAPI {
235
235
  * to {@link CasualSheetsAPI} for hosts, while the `<CasualSheets>` wrapper casts
236
236
  * back to reach `emit` / `markDirty`.
237
237
  */
238
- export function createCasualSheetsAPI(univerAPI: FUniver): CasualSheetsAPI {
238
+ export function createCasualSheetsAPI(
239
+ univerAPI: FUniver,
240
+ initialResources?: IWorkbookData['resources'],
241
+ ): CasualSheetsAPI {
242
+ // SDK-owned snapshot resources (charts / pivots / sparklines panels persist
243
+ // their models here). Univer's `.save()`/`.load()` silently drop any resource
244
+ // it doesn't own, so getContent()/setContent() alone can't round-trip them.
245
+ // We shadow them in an SDK-layer store: setContent captures them before the
246
+ // workbook swap, getContent merges them back onto the Univer snapshot. This
247
+ // keeps the panels' existing `IWorkbookData.resources` read/write working and
248
+ // makes them survive save/reload + collab.
249
+ const CUSTOM_RESOURCE_NAMES = new Set([
250
+ '__casual_sheets_charts__',
251
+ '__casual_sheets_pivots__',
252
+ '__casual_sheets_sparklines__',
253
+ ]);
254
+ const customResources = new Map<string, string>();
255
+ const captureCustom = (resources: IWorkbookData['resources']) => {
256
+ customResources.clear();
257
+ for (const r of resources ?? []) {
258
+ if (CUSTOM_RESOURCE_NAMES.has(r.name) && r.data) customResources.set(r.name, r.data);
259
+ }
260
+ };
261
+ // Seed from the mounted snapshot so pivots/charts saved in a loaded workbook
262
+ // are visible before the first edit.
263
+ captureCustom(initialResources);
264
+
239
265
  // Extracted so importXlsx / setContent reuse the exact same swap semantics.
240
266
  const swapWorkbook = (data: IWorkbookData) => {
241
267
  const current = univerAPI.getActiveWorkbook();
@@ -267,9 +293,18 @@ export function createCasualSheetsAPI(univerAPI: FUniver): CasualSheetsAPI {
267
293
  let documentMode: DocumentMode = 'editing';
268
294
  let readOnlyDisposer: (() => void) | null = null;
269
295
 
270
- const getContent = (): IWorkbookData | null => univerAPI.getActiveWorkbook()?.save() ?? null;
296
+ const getContent = (): IWorkbookData | null => {
297
+ const snap = univerAPI.getActiveWorkbook()?.save() ?? null;
298
+ if (!snap || customResources.size === 0) return snap;
299
+ // Re-attach the SDK-owned resources Univer dropped on save.
300
+ const resources = (snap.resources ?? []).filter((r) => !CUSTOM_RESOURCE_NAMES.has(r.name));
301
+ for (const [name, data] of customResources) resources.push({ name, data });
302
+ return { ...snap, resources };
303
+ };
271
304
 
272
305
  const setContent = (data: IWorkbookData): void => {
306
+ // Capture the SDK-owned resources before Univer drops them on the swap.
307
+ captureCustom(data.resources);
273
308
  swapWorkbook(data);
274
309
  // A fresh snapshot is a clean buffer until the user edits it.
275
310
  markDirty(false);