@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,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
|
+
}
|
package/src/chrome/Toolbar.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|