@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,283 @@
1
+ /**
2
+ * Copyright 2026 Casual Office
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License").
5
+ */
6
+
7
+ /**
8
+ * Tables side panel: lists the formatted tables on the active sheet with
9
+ * inline rename, theme swatches, and delete. Ported from the standalone app's
10
+ * TablesPanel — the app-only `useUI`/`useBusy` couplings are replaced by the
11
+ * panel host's `onClose`, and the Univer facade is reached through `api.univer`.
12
+ */
13
+ import { useEffect, useMemo, useState, type CSSProperties } from 'react';
14
+
15
+ import { ensurePluginByName } from '../univer';
16
+ import type { PanelComponentProps } from './extensions';
17
+ import { Icon } from './Icon';
18
+ import { PanelHeader, PanelEmpty } from './panel-shell';
19
+
20
+ // Univer's six built-in table themes, surfaced as swatches.
21
+ const TABLE_THEMES = [
22
+ { id: 'table-default-0', label: 'Indigo', swatch: '#6280F9' },
23
+ { id: 'table-default-1', label: 'Teal', swatch: '#16BDCA' },
24
+ { id: 'table-default-2', label: 'Green', swatch: '#31C48D' },
25
+ { id: 'table-default-3', label: 'Purple', swatch: '#AC94FA' },
26
+ { id: 'table-default-4', label: 'Pink', swatch: '#F17EBB' },
27
+ { id: 'table-default-5', label: 'Red', swatch: '#F98080' },
28
+ ] as const;
29
+ type TableThemeId = (typeof TABLE_THEMES)[number]['id'];
30
+
31
+ interface TableRange {
32
+ startRow: number;
33
+ startColumn: number;
34
+ endRow: number;
35
+ endColumn: number;
36
+ }
37
+ interface RawTable {
38
+ id: string;
39
+ name: string;
40
+ subUnitId: string;
41
+ range: TableRange;
42
+ }
43
+ interface TableInfo extends RawTable {
44
+ styleId: string;
45
+ }
46
+
47
+ function colLetters(col: number): string {
48
+ let n = col + 1;
49
+ let out = '';
50
+ while (n > 0) {
51
+ const r = (n - 1) % 26;
52
+ out = String.fromCharCode(65 + r) + out;
53
+ n = Math.floor((n - 1) / 26);
54
+ }
55
+ return out;
56
+ }
57
+ function toA1(r: TableRange): string {
58
+ const start = `${colLetters(r.startColumn)}${r.startRow + 1}`;
59
+ const end = `${colLetters(r.endColumn)}${r.endRow + 1}`;
60
+ return start === end ? start : `${start}:${end}`;
61
+ }
62
+
63
+ const REFRESH_CMDS = new Set([
64
+ 'sheet.command.add-table',
65
+ 'sheet.command.delete-table',
66
+ 'sheet.command.set-table-config',
67
+ 'sheet.mutation.add-table',
68
+ 'sheet.mutation.set-table-config',
69
+ 'sheet.mutation.delete-table',
70
+ 'sheet.operation.set-worksheet-activate',
71
+ 'doc.command-replace-snapshot',
72
+ ]);
73
+
74
+ /* eslint-disable @typescript-eslint/no-explicit-any */
75
+ function readTables(univer: any, sheetId: string | null): TableInfo[] {
76
+ try {
77
+ const wb = univer.getActiveWorkbook?.();
78
+ if (!wb) return [];
79
+ const all: RawTable[] = wb.getTableList?.() ?? [];
80
+ return all
81
+ .filter((t) => (sheetId ? t.subUnitId === sheetId : true))
82
+ .map((t) => ({ ...t, styleId: 'table-default-0' }));
83
+ } catch {
84
+ // Table plugin still registering — the CommandExecuted subscription
85
+ // recomputes once it's ready.
86
+ return [];
87
+ }
88
+ }
89
+
90
+ const header: CSSProperties = {
91
+ display: 'flex',
92
+ alignItems: 'center',
93
+ gap: 8,
94
+ padding: '10px 12px',
95
+ borderBottom: '1px solid var(--cs-chrome-border, #edeff3)',
96
+ };
97
+
98
+ export function TablesPanel({ api, onClose }: PanelComponentProps) {
99
+ const univer = (api as any).univer;
100
+ const [tables, setTables] = useState<TableInfo[]>([]);
101
+ const [renaming, setRenaming] = useState<{ id: string; draft: string } | null>(null);
102
+
103
+ useEffect(() => {
104
+ if (!univer) return;
105
+ let cancelled = false;
106
+ let disp: { dispose?: () => void } | undefined;
107
+ const compute = () => {
108
+ if (cancelled) return;
109
+ const sheetId = univer.getActiveWorkbook?.()?.getActiveSheet?.()?.getSheetId?.() ?? null;
110
+ setTables(readTables(univer, sheetId));
111
+ };
112
+ // The table plugin is lazy-loaded — `getTableList()` resolves SheetTableService
113
+ // and throws if the plugin isn't registered yet, so ensure it before reading.
114
+ void ensurePluginByName('table').then(() => {
115
+ if (cancelled) return;
116
+ compute();
117
+ disp = univer.addEvent(univer.Event.CommandExecuted, (e: { id?: string }) => {
118
+ if (e.id && REFRESH_CMDS.has(e.id)) compute();
119
+ });
120
+ });
121
+ return () => {
122
+ cancelled = true;
123
+ disp?.dispose?.();
124
+ };
125
+ }, [univer]);
126
+
127
+ const themesById = useMemo(() => {
128
+ const m = new Map<string, (typeof TABLE_THEMES)[number]>();
129
+ for (const t of TABLE_THEMES) m.set(t.id, t);
130
+ return m;
131
+ }, []);
132
+
133
+ const empty = tables.length === 0;
134
+
135
+ const onRenameCommit = async (id: string, currentName: string) => {
136
+ if (!renaming || renaming.id !== id) return;
137
+ const next = renaming.draft.trim();
138
+ setRenaming(null);
139
+ if (!next || next === currentName) return;
140
+ const sheet = univer.getActiveWorkbook?.()?.getActiveSheet?.();
141
+ if (!sheet) return;
142
+ await ensurePluginByName('table');
143
+ const ok = await Promise.resolve(sheet.setTableName?.(id, next));
144
+ if (ok === false) console.warn(`[tables] rename rejected: "${next}"`);
145
+ };
146
+
147
+ const onPickTheme = async (id: string, themeId: TableThemeId) => {
148
+ const wb = univer.getActiveWorkbook?.();
149
+ if (!wb) return;
150
+ await ensurePluginByName('table');
151
+ univer.executeCommand('sheet.command.set-table-config', {
152
+ unitId: wb.getId(),
153
+ tableId: id,
154
+ theme: themeId,
155
+ });
156
+ };
157
+
158
+ const onDelete = async (id: string) => {
159
+ const wb = univer.getActiveWorkbook?.();
160
+ if (!wb) return;
161
+ await ensurePluginByName('table');
162
+ wb.removeTable?.(id);
163
+ };
164
+
165
+ return (
166
+ <div
167
+ data-testid="cs-tables-panel"
168
+ style={{ height: '100%', display: 'flex', flexDirection: 'column' }}
169
+ >
170
+ <PanelHeader icon="table" title="Tables" count={tables.length} onClose={onClose} />
171
+
172
+ <div style={{ flex: 1, overflow: 'auto', padding: 12 }}>
173
+ {empty ? (
174
+ <PanelEmpty icon="table_rows" title="No tables on this sheet" testId="cs-tables-panel-empty">
175
+ Select your data, then use <strong>Insert → Table</strong> from the menu.
176
+ </PanelEmpty>
177
+ ) : (
178
+ <ul
179
+ style={{
180
+ listStyle: 'none',
181
+ margin: 0,
182
+ padding: 0,
183
+ display: 'flex',
184
+ flexDirection: 'column',
185
+ gap: 8,
186
+ }}
187
+ >
188
+ {tables.map((t) => {
189
+ const isRenaming = renaming?.id === t.id;
190
+ const theme = themesById.get(t.styleId);
191
+ return (
192
+ <li
193
+ key={t.id}
194
+ data-testid={`cs-tables-panel-row-${t.id}`}
195
+ style={{
196
+ border: '1px solid var(--cs-chrome-border, #edeff3)',
197
+ borderRadius: 8,
198
+ padding: 10,
199
+ display: 'flex',
200
+ flexDirection: 'column',
201
+ gap: 6,
202
+ }}
203
+ >
204
+ <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
205
+ {isRenaming ? (
206
+ <input
207
+ autoFocus
208
+ value={renaming.draft}
209
+ onChange={(e) => setRenaming({ id: t.id, draft: e.target.value })}
210
+ onBlur={() => onRenameCommit(t.id, t.name)}
211
+ onKeyDown={(e) => {
212
+ if (e.key === 'Enter') onRenameCommit(t.id, t.name);
213
+ if (e.key === 'Escape') setRenaming(null);
214
+ }}
215
+ style={{ flex: 1, font: 'inherit', padding: '2px 4px' }}
216
+ />
217
+ ) : (
218
+ <button
219
+ type="button"
220
+ onClick={() => setRenaming({ id: t.id, draft: t.name })}
221
+ title="Click to rename"
222
+ style={{
223
+ flex: 1,
224
+ textAlign: 'left',
225
+ border: 'none',
226
+ background: 'transparent',
227
+ cursor: 'pointer',
228
+ font: 'inherit',
229
+ fontWeight: 600,
230
+ color: 'inherit',
231
+ }}
232
+ >
233
+ {t.name}
234
+ </button>
235
+ )}
236
+ <span style={{ opacity: 0.6, fontSize: 12 }}>{toA1(t.range)}</span>
237
+ <button
238
+ type="button"
239
+ aria-label={`Delete table ${t.name}`}
240
+ title="Delete table"
241
+ onClick={() => onDelete(t.id)}
242
+ style={{
243
+ border: 'none',
244
+ background: 'transparent',
245
+ cursor: 'pointer',
246
+ color: 'inherit',
247
+ }}
248
+ >
249
+ <Icon name="delete" size={16} />
250
+ </button>
251
+ </div>
252
+ <div role="group" aria-label="Table theme" style={{ display: 'flex', gap: 4 }}>
253
+ {TABLE_THEMES.map((opt) => (
254
+ <button
255
+ key={opt.id}
256
+ type="button"
257
+ title={opt.label}
258
+ aria-label={opt.label}
259
+ aria-pressed={theme?.id === opt.id}
260
+ onClick={() => onPickTheme(t.id, opt.id)}
261
+ style={{
262
+ width: 18,
263
+ height: 18,
264
+ borderRadius: 4,
265
+ cursor: 'pointer',
266
+ background: opt.swatch,
267
+ border:
268
+ theme?.id === opt.id
269
+ ? '2px solid var(--cs-chrome-active-fg, #0e7490)'
270
+ : '1px solid rgba(0,0,0,0.15)',
271
+ }}
272
+ />
273
+ ))}
274
+ </div>
275
+ </li>
276
+ );
277
+ })}
278
+ </ul>
279
+ )}
280
+ </div>
281
+ </div>
282
+ );
283
+ }
@@ -371,7 +371,13 @@ const BAR_STYLE: CSSProperties = {
371
371
  background: 'var(--cs-chrome-bg, #eef1f5)',
372
372
  flex: '0 0 auto',
373
373
  userSelect: 'none',
374
- flexWrap: 'wrap',
374
+ // Single row that scrolls horizontally when the controls don't fit — never
375
+ // wrap into a broken second row with orphaned icons (matches Google Sheets /
376
+ // the standalone app's overflow-scroll toolbar). Scrollbar hidden; the row
377
+ // stays one line and the groups keep their order.
378
+ flexWrap: 'nowrap',
379
+ overflowX: 'auto',
380
+ scrollbarWidth: 'none',
375
381
  };
376
382
 
377
383
  const BTN_STYLE: CSSProperties = {
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Copyright 2026 Casual Office
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License").
5
+ */
6
+
7
+ /**
8
+ * Panel store for the SDK chrome's side-panel rail (Tables/Charts/Comments/…).
9
+ *
10
+ * A single `openPanelId` is the whole state — that IS the mutex: opening one
11
+ * panel closes any other, exactly like the standalone app's PanelMutex, but
12
+ * without the app-only `ui-context`. The rail toggles panels through here and
13
+ * the panel host renders whichever id is open. Kept deliberately tiny so the
14
+ * chrome stays a thin shell around Univer.
15
+ */
16
+ import { createContext, useContext, useMemo, useState, type ReactNode } from 'react';
17
+
18
+ export interface PanelStore {
19
+ /** The id of the currently-open panel, or null when the rail is all-closed. */
20
+ openPanelId: string | null;
21
+ /** Open `id`, replacing whatever was open (mutex). */
22
+ open: (id: string) => void;
23
+ /** Close the open panel. */
24
+ close: () => void;
25
+ /** Toggle `id`: open it if closed, close it if it's the open one. */
26
+ toggle: (id: string) => void;
27
+ }
28
+
29
+ const PanelContext = createContext<PanelStore | null>(null);
30
+
31
+ export function PanelProvider({ children }: { children: ReactNode }) {
32
+ const [openPanelId, setOpenPanelId] = useState<string | null>(null);
33
+ const store = useMemo<PanelStore>(
34
+ () => ({
35
+ openPanelId,
36
+ open: (id) => setOpenPanelId(id),
37
+ close: () => setOpenPanelId(null),
38
+ toggle: (id) => setOpenPanelId((cur) => (cur === id ? null : id)),
39
+ }),
40
+ [openPanelId],
41
+ );
42
+ return <PanelContext.Provider value={store}>{children}</PanelContext.Provider>;
43
+ }
44
+
45
+ /** Read the panel store. Returns a no-op store when used outside a provider. */
46
+ export function usePanels(): PanelStore {
47
+ return (
48
+ useContext(PanelContext) ?? {
49
+ openPanelId: null,
50
+ open: () => {},
51
+ close: () => {},
52
+ toggle: () => {},
53
+ }
54
+ );
55
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Copyright 2026 Casual Office
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License").
5
+ */
6
+
7
+ /**
8
+ * Built-in side panels the SDK chrome ships (mirrors `BUILT_IN_DIALOGS`). Each
9
+ * entry gets a rail button + a body rendered by the panel host. Hosts can add
10
+ * more via `extensions.panels` (see extensions.ts) — those are merged after
11
+ * these on the rail.
12
+ */
13
+ import { lazy, type ComponentType } from 'react';
14
+
15
+ import type { PanelComponentProps } from './extensions';
16
+ import { TablesPanel } from './TablesPanel';
17
+ import { PivotFieldsPanel } from './PivotFieldsPanel';
18
+ import { CommentsPanel } from './CommentsPanel';
19
+ import { HistoryPanel } from './HistoryPanel';
20
+ // Charts panel pulls echarts — lazy so it (and echarts) only load when the
21
+ // panel is first opened. PanelHost renders panels inside a Suspense boundary.
22
+ const ChartsPanel = lazy(() =>
23
+ import('../charts/ChartsPanel').then((m) => ({ default: m.ChartsPanel })),
24
+ );
25
+
26
+ export interface BuiltInPanel {
27
+ /** Stable id (rail `data-testid` `cs-panel-rail-<id>`, mutex key). */
28
+ id: string;
29
+ /** Rail button tooltip / aria-label. */
30
+ label: string;
31
+ /** Material Symbols icon name for the rail button. */
32
+ icon: string;
33
+ /** The panel body; receives `{ api, onClose }`. */
34
+ component: ComponentType<PanelComponentProps>;
35
+ }
36
+
37
+ export const BUILT_IN_PANELS: BuiltInPanel[] = [
38
+ { id: 'tables', label: 'Tables', icon: 'table', component: TablesPanel },
39
+ {
40
+ id: 'pivot',
41
+ label: 'PivotTable Fields',
42
+ icon: 'pivot_table_chart',
43
+ component: PivotFieldsPanel,
44
+ },
45
+ { id: 'charts', label: 'Charts', icon: 'analytics', component: ChartsPanel },
46
+ { id: 'comments', label: 'Comments', icon: 'forum', component: CommentsPanel },
47
+ { id: 'history', label: 'History', icon: 'history', component: HistoryPanel },
48
+ ];
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Copyright 2026 Casual Office
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License").
5
+ */
6
+
7
+ /**
8
+ * Shared chrome for the side panels — a consistent, polished header (icon +
9
+ * title + optional count/actions + hover-capable close) and small building
10
+ * blocks, so every panel (Tables / Charts / Pivot / Comments / History) looks
11
+ * the same. Styling maps to the design-system tokens the host loads
12
+ * (`--color-*`), falling back to the chrome vars, then a hardcoded default —
13
+ * so it looks right standalone and themed inside a host.
14
+ */
15
+ import { useState, type CSSProperties, type ReactNode } from 'react';
16
+
17
+ import { Icon } from './Icon';
18
+
19
+ const MUTED = 'var(--color-text-secondary, var(--cs-chrome-muted, #605e5c))';
20
+ const DIVIDER = 'var(--color-divider, var(--cs-chrome-border, #edeff3))';
21
+
22
+ const headerStyle: CSSProperties = {
23
+ display: 'flex',
24
+ alignItems: 'center',
25
+ gap: 10,
26
+ padding: '10px 14px',
27
+ flex: '0 0 auto',
28
+ background: 'var(--color-surface, var(--cs-chrome-input-bg, #ffffff))',
29
+ borderBottom: `1px solid ${DIVIDER}`,
30
+ };
31
+
32
+ const titleStyle: CSSProperties = {
33
+ fontWeight: 600,
34
+ fontSize: 14,
35
+ flex: 1,
36
+ minWidth: 0,
37
+ overflow: 'hidden',
38
+ textOverflow: 'ellipsis',
39
+ whiteSpace: 'nowrap',
40
+ };
41
+
42
+ const countStyle: CSSProperties = {
43
+ fontSize: 11,
44
+ fontWeight: 600,
45
+ minWidth: 18,
46
+ height: 18,
47
+ padding: '0 5px',
48
+ display: 'inline-flex',
49
+ alignItems: 'center',
50
+ justifyContent: 'center',
51
+ borderRadius: 9,
52
+ background: 'var(--color-hover, rgba(15,23,42,0.06))',
53
+ color: MUTED,
54
+ };
55
+
56
+ /** A ghost icon button with a real hover state (inline styles can't do :hover). */
57
+ export function IconButton({
58
+ name,
59
+ label,
60
+ onClick,
61
+ size = 18,
62
+ }: {
63
+ name: string;
64
+ label: string;
65
+ onClick: () => void;
66
+ size?: number;
67
+ }) {
68
+ const [hover, setHover] = useState(false);
69
+ return (
70
+ <button
71
+ type="button"
72
+ aria-label={label}
73
+ title={label}
74
+ onClick={onClick}
75
+ onMouseEnter={() => setHover(true)}
76
+ onMouseLeave={() => setHover(false)}
77
+ style={{
78
+ flex: '0 0 auto',
79
+ display: 'inline-flex',
80
+ alignItems: 'center',
81
+ justifyContent: 'center',
82
+ width: 26,
83
+ height: 26,
84
+ border: 'none',
85
+ borderRadius: 6,
86
+ cursor: 'pointer',
87
+ color: hover ? 'var(--color-text, var(--cs-chrome-fg, #201f1e))' : MUTED,
88
+ background: hover ? 'var(--color-hover, rgba(15,23,42,0.06))' : 'transparent',
89
+ }}
90
+ >
91
+ <Icon name={name} size={size} />
92
+ </button>
93
+ );
94
+ }
95
+
96
+ export function PanelHeader({
97
+ icon,
98
+ title,
99
+ count,
100
+ onClose,
101
+ actions,
102
+ }: {
103
+ icon: string;
104
+ title: string;
105
+ count?: number;
106
+ onClose: () => void;
107
+ /** Optional trailing actions (e.g. an add button) shown before the close. */
108
+ actions?: ReactNode;
109
+ }) {
110
+ return (
111
+ <header style={headerStyle}>
112
+ <Icon name={icon} size={18} style={{ color: MUTED, flex: '0 0 auto' }} />
113
+ <span style={titleStyle}>{title}</span>
114
+ {count != null && count > 0 && <span style={countStyle}>{count}</span>}
115
+ {actions}
116
+ <IconButton name="close" label={`Close ${title} panel`} onClick={onClose} />
117
+ </header>
118
+ );
119
+ }
120
+
121
+ /** Centered empty-state block for panels with no content yet. */
122
+ export function PanelEmpty({
123
+ icon,
124
+ title,
125
+ children,
126
+ testId,
127
+ }: {
128
+ icon: string;
129
+ title: string;
130
+ children?: ReactNode;
131
+ testId?: string;
132
+ }) {
133
+ return (
134
+ <div
135
+ data-testid={testId}
136
+ style={{
137
+ textAlign: 'center',
138
+ padding: '32px 20px',
139
+ color: MUTED,
140
+ display: 'flex',
141
+ flexDirection: 'column',
142
+ alignItems: 'center',
143
+ gap: 6,
144
+ }}
145
+ >
146
+ <Icon name={icon} size={40} style={{ opacity: 0.35 }} />
147
+ <div style={{ fontWeight: 600, fontSize: 14, color: 'var(--color-text, inherit)' }}>{title}</div>
148
+ {children && <div style={{ fontSize: 13, lineHeight: 1.5 }}>{children}</div>}
149
+ </div>
150
+ );
151
+ }
@@ -0,0 +1,139 @@
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 { computePivot, type PivotGrid, type SourceMatrix } from './compute';
19
+ import type { PivotModel } from './types';
20
+
21
+ /**
22
+ * Read the source range, compute the pivot, then write the resulting
23
+ * cell grid into the target sheet starting at `model.target`. The
24
+ * write is one `setRangeValues` call so it lands as a single Univer
25
+ * mutation — collab and undo both treat it atomically.
26
+ *
27
+ * If `prevExtent` is supplied (refresh path), the previous output
28
+ * rectangle is cleared first so a shrunk pivot doesn't leave residual
29
+ * rows from the prior write. Insert paths pass null.
30
+ */
31
+ export function applyPivot(
32
+ api: FUniver,
33
+ model: PivotModel,
34
+ prevExtent?: { rows: number; cols: number } | null,
35
+ ): { rows: number; cols: number } | null {
36
+ const wb = api.getActiveWorkbook();
37
+ if (!wb) return null;
38
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
39
+ const sheets = wb.getSheets() as any[];
40
+ const sourceWs = sheets.find((s) => s.getSheetId?.() === model.sourceSheetId);
41
+ const targetWs = sheets.find((s) => s.getSheetId?.() === model.targetSheetId);
42
+ if (!sourceWs || !targetWs) return null;
43
+
44
+ const matrix = readSourceMatrix(sourceWs, model.source);
45
+ if (matrix.records.length === 0) return null;
46
+
47
+ const { grid } = computePivot(matrix, model);
48
+ if (grid.length === 0) return null;
49
+
50
+ // Clear the previous extent first if we have one — keeps stale rows
51
+ // from leaking through when the new grid is smaller (e.g. a filter
52
+ // narrowed the row keys, or the source range shrank).
53
+ if (prevExtent && (prevExtent.rows > grid.length || prevExtent.cols > (grid[0]?.length ?? 0))) {
54
+ clearGridArea(targetWs, model.target, prevExtent);
55
+ }
56
+
57
+ writeGridToSheet(targetWs, model.target, grid);
58
+ return { rows: grid.length, cols: grid[0]?.length ?? 0 };
59
+ }
60
+
61
+ /**
62
+ * P1 — re-read source data and re-apply the pivot. Refreshing is what
63
+ * makes pivots track upstream edits; without it the output frozen at
64
+ * insert time goes stale. Returns the new extent (or null on failure).
65
+ */
66
+ export function refreshPivot(
67
+ api: FUniver,
68
+ model: PivotModel,
69
+ ): { rows: number; cols: number } | null {
70
+ return applyPivot(api, model, model.lastOutputExtent ?? null);
71
+ }
72
+
73
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
74
+ function readSourceMatrix(ws: any, src: PivotModel['source']): SourceMatrix {
75
+ const headers: string[] = [];
76
+ for (let c = src.startColumn; c <= src.endColumn; c++) {
77
+ const v = ws.getRange(src.startRow, c).getValue();
78
+ headers.push(v == null ? '' : String(v));
79
+ }
80
+ const records: Array<Array<string | number | null>> = [];
81
+ for (let r = src.startRow + 1; r <= src.endRow; r++) {
82
+ const row: Array<string | number | null> = [];
83
+ let anyValue = false;
84
+ for (let c = src.startColumn; c <= src.endColumn; c++) {
85
+ const v = ws.getRange(r, c).getValue();
86
+ if (v == null || v === '') {
87
+ row.push(null);
88
+ } else if (typeof v === 'number' || typeof v === 'string') {
89
+ row.push(v);
90
+ anyValue = true;
91
+ } else if (typeof v === 'boolean') {
92
+ row.push(v ? 1 : 0);
93
+ anyValue = true;
94
+ } else {
95
+ row.push(String(v));
96
+ anyValue = true;
97
+ }
98
+ }
99
+ // Skip blank rows — Excel treats them as terminators, but a
100
+ // misclick that includes an extra blank row in the selection
101
+ // shouldn't introduce a phantom "(blank)" key. Drop them.
102
+ if (anyValue) records.push(row);
103
+ }
104
+ return { headers, records };
105
+ }
106
+
107
+ /** Blank an arbitrary rectangle on the sheet — used by refresh to
108
+ * reset the previous output before writing the new grid. */
109
+ function clearGridArea(
110
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
111
+ ws: any,
112
+ target: { row: number; column: number },
113
+ extent: { rows: number; cols: number },
114
+ ): void {
115
+ const blank: Array<Array<{ v: null }>> = [];
116
+ for (let r = 0; r < extent.rows; r += 1) {
117
+ const row: Array<{ v: null }> = [];
118
+ for (let c = 0; c < extent.cols; c += 1) {
119
+ row.push({ v: null });
120
+ }
121
+ blank.push(row);
122
+ }
123
+ const range = ws.getRange(target.row, target.column, extent.rows, extent.cols);
124
+ range.setValues(blank);
125
+ }
126
+
127
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
128
+ function writeGridToSheet(ws: any, target: { row: number; column: number }, grid: PivotGrid): void {
129
+ const rows = grid.length;
130
+ const cols = grid[0]?.length ?? 0;
131
+ if (rows === 0 || cols === 0) return;
132
+ // Build the IRange-shaped object setRangeValues expects: a 2D
133
+ // array of `{ v }` cell objects keyed by `[r-offset][c-offset]`.
134
+ const cellMatrix: Array<Array<{ v: string | number | null }>> = grid.map((rowVals) =>
135
+ rowVals.map((v) => ({ v })),
136
+ );
137
+ const range = ws.getRange(target.row, target.column, rows, cols);
138
+ range.setValues(cellMatrix);
139
+ }