@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.
- package/dist/{api-DJhuCjhn.d.cts → api-cyO4EA4_.d.cts} +1 -1
- package/dist/{api-DJhuCjhn.d.ts → api-cyO4EA4_.d.ts} +1 -1
- package/dist/{attachCollab-BmCRT4V_.d.ts → attachCollab-BT4fVzxP.d.ts} +1 -1
- package/dist/{attachCollab-DIUQCCrq.d.cts → attachCollab-DHjb5XYK.d.cts} +1 -1
- package/dist/chrome.cjs +60 -3
- package/dist/chrome.cjs.map +1 -1
- package/dist/chrome.d.cts +3 -3
- package/dist/chrome.d.ts +3 -3
- package/dist/chrome.js +60 -3
- package/dist/chrome.js.map +1 -1
- package/dist/collab.d.cts +2 -2
- package/dist/collab.d.ts +2 -2
- package/dist/embed/embed-runtime.js +205 -161
- package/dist/{extensions-DZnqTEN2.d.ts → extensions-Cni7mbEm.d.ts} +1 -1
- package/dist/{extensions-FFqdLpk_.d.cts → extensions-DMmPnIYB.d.cts} +1 -1
- package/dist/index.cjs +6424 -1559
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +3 -3
- package/dist/index.d.ts +3 -3
- package/dist/index.js +6409 -1530
- package/dist/index.js.map +1 -1
- package/dist/sheets.cjs +5426 -561
- package/dist/sheets.cjs.map +1 -1
- package/dist/sheets.d.cts +5 -5
- package/dist/sheets.d.ts +5 -5
- package/dist/sheets.js +5415 -536
- package/dist/sheets.js.map +1 -1
- package/package.json +8 -7
- package/src/charts/ChartContextMenu.tsx +264 -0
- package/src/charts/ChartLayer.tsx +333 -0
- package/src/charts/ChartOverlay.tsx +293 -0
- package/src/charts/ChartsPanel.tsx +232 -0
- package/src/charts/FormatChartDialog.tsx +419 -0
- package/src/charts/InsertChartDialog.tsx +478 -0
- package/src/charts/build-option.ts +476 -0
- package/src/charts/charts-context.tsx +205 -0
- package/src/charts/echarts-init.ts +58 -0
- package/src/charts/hit-test.ts +130 -0
- package/src/charts/insert-chart.ts +106 -0
- package/src/charts/naming.ts +38 -0
- package/src/charts/render-to-png.ts +117 -0
- package/src/charts/resources.ts +108 -0
- package/src/charts/types.ts +239 -0
- package/src/charts/univer-dom.ts +102 -0
- package/src/chrome/CommentsPanel.tsx +418 -0
- package/src/chrome/HistoryPanel.tsx +304 -0
- package/src/chrome/InsertPivotDialog.tsx +74 -2
- package/src/chrome/PanelHost.tsx +56 -0
- package/src/chrome/PanelRail.tsx +90 -0
- package/src/chrome/PivotFieldsPanel.tsx +1021 -0
- package/src/chrome/TablesPanel.tsx +283 -0
- package/src/chrome/Toolbar.tsx +7 -1
- package/src/chrome/panel-context.tsx +55 -0
- package/src/chrome/panel-registry.ts +48 -0
- package/src/chrome/panel-shell.tsx +151 -0
- package/src/pivots/apply.ts +139 -0
- package/src/pivots/compute.ts +604 -0
- package/src/pivots/fields-model.ts +267 -0
- package/src/pivots/types.ts +137 -0
- package/src/sheets/CasualSheets.tsx +61 -35
- 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
|
-
<
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
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
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
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
|
-
|
|
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
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
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(
|
|
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 =>
|
|
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);
|