@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,304 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright 2026 Casual Office
|
|
3
|
+
*
|
|
4
|
+
* Licensed under the Apache License, Version 2.0 (the "License").
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* History / Activity side panel — a self-contained, in-memory activity feed
|
|
9
|
+
* of user-facing edits in the current session.
|
|
10
|
+
*
|
|
11
|
+
* Ported from the standalone app's HistoryPanel, but DECOUPLED from every
|
|
12
|
+
* app-only coupling: the app version drove off two host-owned sources (a
|
|
13
|
+
* shared Yjs op-log and IndexedDB-backed snapshot version history via
|
|
14
|
+
* `useCollab`/`usePresence`/`local-history`). None of that exists in the SDK,
|
|
15
|
+
* and persistent version history stays HOST-OWNED. So instead this panel
|
|
16
|
+
* subscribes to the live `ICommandService.onCommandExecuted` stream, keeps a
|
|
17
|
+
* bounded in-memory ring (last 100) of user-facing command executions, maps
|
|
18
|
+
* each command id to a friendly label, and renders them newest-first.
|
|
19
|
+
*
|
|
20
|
+
* Ordering is derived from a monotonically incrementing sequence counter
|
|
21
|
+
* (NOT wall-clock time) so nothing calls `Date.now()`/`new Date()` at module
|
|
22
|
+
* load; a per-entry relative-age string is computed lazily at render time from
|
|
23
|
+
* a capture timestamp taken inside the (post-mount) event handler.
|
|
24
|
+
*
|
|
25
|
+
* The app-only `useUI().toggleHistoryPanel` is replaced by the panel host's
|
|
26
|
+
* `onClose`; the Univer facade + services are reached through the `api` prop's
|
|
27
|
+
* `_injector`, matching StatusBar / FormulaBar precedent.
|
|
28
|
+
*/
|
|
29
|
+
import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
|
|
30
|
+
import { CommandType, ICommandService, type ICommandInfo } from '@univerjs/core';
|
|
31
|
+
|
|
32
|
+
import type { PanelComponentProps } from './extensions';
|
|
33
|
+
import { Icon } from './Icon';
|
|
34
|
+
import { PanelHeader } from './panel-shell';
|
|
35
|
+
|
|
36
|
+
const RING_CAP = 100;
|
|
37
|
+
|
|
38
|
+
interface ActivityEntry {
|
|
39
|
+
/** Monotonic sequence — sole source of ordering (no wall-clock at module load). */
|
|
40
|
+
seq: number;
|
|
41
|
+
/** Command id that fired (e.g. `sheet.command.set-range-values`). */
|
|
42
|
+
id: string;
|
|
43
|
+
/** Friendly one-line label. */
|
|
44
|
+
label: string;
|
|
45
|
+
/** Icon glyph for the row. */
|
|
46
|
+
icon: string;
|
|
47
|
+
/** Capture time (ms). Taken inside the handler, post-mount — safe. */
|
|
48
|
+
t: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Map a user-facing COMMAND id to a friendly label + icon. Commands (not
|
|
53
|
+
* mutations) are the user-intent layer, so one command == one activity row —
|
|
54
|
+
* no de-duping a single paste into a dozen mutation rows. Anything not mapped
|
|
55
|
+
* still records with a de-namespaced fallback label so the feed stays honest.
|
|
56
|
+
*/
|
|
57
|
+
const COMMAND_LABELS: Record<string, { label: string; icon: string }> = {
|
|
58
|
+
'sheet.command.set-range-values': { label: 'Edited cells', icon: 'edit' },
|
|
59
|
+
'sheet.command.set-range-bold': { label: 'Toggled bold', icon: 'format_bold' },
|
|
60
|
+
'sheet.command.set-range-italic': { label: 'Toggled italic', icon: 'format_italic' },
|
|
61
|
+
'sheet.command.set-range-underline': { label: 'Toggled underline', icon: 'format_underlined' },
|
|
62
|
+
'sheet.command.set-range-stroke-through': {
|
|
63
|
+
label: 'Toggled strikethrough',
|
|
64
|
+
icon: 'strikethrough_s',
|
|
65
|
+
},
|
|
66
|
+
'sheet.command.set-range-font-family': { label: 'Changed font', icon: 'font_download' },
|
|
67
|
+
'sheet.command.set-range-font-size': { label: 'Changed font size', icon: 'format_size' },
|
|
68
|
+
'sheet.command.set-range-text-color': { label: 'Changed text color', icon: 'format_color_text' },
|
|
69
|
+
'sheet.command.set-background-color': { label: 'Changed fill color', icon: 'format_color_fill' },
|
|
70
|
+
'sheet.command.set-range-text-align': { label: 'Changed alignment', icon: 'format_align_left' },
|
|
71
|
+
'sheet.command.set-range-vertical-align': {
|
|
72
|
+
label: 'Changed alignment',
|
|
73
|
+
icon: 'vertical_align_center',
|
|
74
|
+
},
|
|
75
|
+
'sheet.command.set-border-command': { label: 'Changed borders', icon: 'border_all' },
|
|
76
|
+
'sheet.command.clear-selection-content': { label: 'Cleared cell contents', icon: 'backspace' },
|
|
77
|
+
'sheet.command.clear-selection-format': { label: 'Cleared formatting', icon: 'format_clear' },
|
|
78
|
+
'sheet.command.clear-selection-all': { label: 'Cleared cells', icon: 'clear_all' },
|
|
79
|
+
'sheet.command.insert-row': { label: 'Inserted row(s)', icon: 'add_row_above' },
|
|
80
|
+
'sheet.command.insert-row-before': { label: 'Inserted row above', icon: 'add_row_above' },
|
|
81
|
+
'sheet.command.insert-row-after': { label: 'Inserted row below', icon: 'add_row_below' },
|
|
82
|
+
'sheet.command.insert-col': { label: 'Inserted column(s)', icon: 'add_column_left' },
|
|
83
|
+
'sheet.command.insert-col-before': { label: 'Inserted column left', icon: 'add_column_left' },
|
|
84
|
+
'sheet.command.insert-col-after': { label: 'Inserted column right', icon: 'add_column_right' },
|
|
85
|
+
'sheet.command.remove-row': { label: 'Deleted row(s)', icon: 'delete' },
|
|
86
|
+
'sheet.command.remove-col': { label: 'Deleted column(s)', icon: 'delete' },
|
|
87
|
+
'sheet.command.delete-range-move-up': { label: 'Deleted cells', icon: 'delete' },
|
|
88
|
+
'sheet.command.delete-range-move-left': { label: 'Deleted cells', icon: 'delete' },
|
|
89
|
+
'sheet.command.insert-range-move-down': { label: 'Inserted cells', icon: 'add' },
|
|
90
|
+
'sheet.command.insert-range-move-right': { label: 'Inserted cells', icon: 'add' },
|
|
91
|
+
'sheet.command.move-range': { label: 'Moved cells', icon: 'open_with' },
|
|
92
|
+
'sheet.command.move-rows': { label: 'Moved row(s)', icon: 'swap_vert' },
|
|
93
|
+
'sheet.command.move-cols': { label: 'Moved column(s)', icon: 'swap_horiz' },
|
|
94
|
+
'sheet.command.add-worksheet-merge-all': { label: 'Merged cells', icon: 'cell_merge' },
|
|
95
|
+
'sheet.command.remove-worksheet-merge': { label: 'Unmerged cells', icon: 'grid_on' },
|
|
96
|
+
'sheet.command.set-worksheet-row-height': { label: 'Resized row(s)', icon: 'height' },
|
|
97
|
+
'sheet.command.set-worksheet-col-width': { label: 'Resized column(s)', icon: 'width_normal' },
|
|
98
|
+
'sheet.command.set-row-hidden': { label: 'Hid row(s)', icon: 'visibility_off' },
|
|
99
|
+
'sheet.command.set-specific-rows-visible': { label: 'Showed row(s)', icon: 'visibility' },
|
|
100
|
+
'sheet.command.set-col-hidden': { label: 'Hid column(s)', icon: 'visibility_off' },
|
|
101
|
+
'sheet.command.set-specific-cols-visible': { label: 'Showed column(s)', icon: 'visibility' },
|
|
102
|
+
'sheet.command.set-frozen': { label: 'Changed freeze panes', icon: 'ac_unit' },
|
|
103
|
+
'sheet.command.insert-sheet': { label: 'Added a sheet', icon: 'add_box' },
|
|
104
|
+
'sheet.command.remove-sheet': { label: 'Removed a sheet', icon: 'delete' },
|
|
105
|
+
'sheet.command.set-worksheet-name': {
|
|
106
|
+
label: 'Renamed a sheet',
|
|
107
|
+
icon: 'drive_file_rename_outline',
|
|
108
|
+
},
|
|
109
|
+
'sheet.command.set-worksheet-order': { label: 'Reordered sheets', icon: 'reorder' },
|
|
110
|
+
'sheet.command.set-tab-color': { label: 'Changed tab color', icon: 'palette' },
|
|
111
|
+
'sheet.command.paste': { label: 'Pasted', icon: 'content_paste' },
|
|
112
|
+
'sheet.command.copy': { label: 'Copied', icon: 'content_copy' },
|
|
113
|
+
'sheet.command.cut': { label: 'Cut', icon: 'content_cut' },
|
|
114
|
+
'sheet.command.add-table': { label: 'Inserted a table', icon: 'table' },
|
|
115
|
+
'sheet.command.delete-table': { label: 'Deleted a table', icon: 'delete' },
|
|
116
|
+
'sheet.command.numfmt.set.numfmt': { label: 'Changed number format', icon: 'tag' },
|
|
117
|
+
'univer.command.undo': { label: 'Undo', icon: 'undo' },
|
|
118
|
+
'univer.command.redo': { label: 'Redo', icon: 'redo' },
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Which command ids count as "user-facing activity". We only record
|
|
123
|
+
* `CommandType.COMMAND` (intent) executions, and we skip pure selection /
|
|
124
|
+
* navigation / scroll operations even though some of those are dispatched as
|
|
125
|
+
* COMMANDs — they're noise, not edits. The allow-list above IS the primary
|
|
126
|
+
* signal; unmapped COMMANDs still record with a fallback label unless they
|
|
127
|
+
* match an obvious noise prefix.
|
|
128
|
+
*/
|
|
129
|
+
const NOISE_PREFIXES = [
|
|
130
|
+
'sheet.operation.',
|
|
131
|
+
'sheet.command.set-selection',
|
|
132
|
+
'sheet.command.scroll',
|
|
133
|
+
'sheet.command.set-zoom',
|
|
134
|
+
'sheet.command.set-activate-cell-edit',
|
|
135
|
+
'sheet.command.set-cell-edit-visible',
|
|
136
|
+
'sheet.command.set-editor',
|
|
137
|
+
'formula.',
|
|
138
|
+
'univer.command.set-current-locale',
|
|
139
|
+
];
|
|
140
|
+
|
|
141
|
+
function isNoise(id: string): boolean {
|
|
142
|
+
return NOISE_PREFIXES.some((p) => id.startsWith(p));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function labelFor(id: string): { label: string; icon: string } {
|
|
146
|
+
const known = COMMAND_LABELS[id];
|
|
147
|
+
if (known) return known;
|
|
148
|
+
// De-namespace the id into something human-ish.
|
|
149
|
+
const tail = id
|
|
150
|
+
.replace(/^sheet\.command\./, '')
|
|
151
|
+
.replace(/^univer\.command\./, '')
|
|
152
|
+
.replace(/[.-]/g, ' ')
|
|
153
|
+
.trim();
|
|
154
|
+
return { label: tail ? tail.charAt(0).toUpperCase() + tail.slice(1) : 'Change', icon: 'bolt' };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function relativeTime(t: number, now: number): string {
|
|
158
|
+
const delta = now - t;
|
|
159
|
+
if (delta < 5_000) return 'just now';
|
|
160
|
+
if (delta < 60_000) return `${Math.floor(delta / 1000)}s ago`;
|
|
161
|
+
if (delta < 3_600_000) return `${Math.floor(delta / 60_000)}m ago`;
|
|
162
|
+
return `${Math.floor(delta / 3_600_000)}h ago`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const headerStyle: CSSProperties = {
|
|
166
|
+
display: 'flex',
|
|
167
|
+
alignItems: 'center',
|
|
168
|
+
gap: 8,
|
|
169
|
+
padding: '10px 12px',
|
|
170
|
+
borderBottom: '1px solid var(--cs-chrome-border, #edeff3)',
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
export function HistoryPanel({ api, onClose }: PanelComponentProps) {
|
|
174
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
175
|
+
const univer = (api as any).univer;
|
|
176
|
+
const [entries, setEntries] = useState<ActivityEntry[]>([]);
|
|
177
|
+
// Ordering counter — monotonic, initialised at 0 (no wall-clock at load).
|
|
178
|
+
const seqRef = useRef(0);
|
|
179
|
+
// Re-render tick so relative times refresh even without new activity.
|
|
180
|
+
const [nowTick, setNowTick] = useState(0);
|
|
181
|
+
|
|
182
|
+
useEffect(() => {
|
|
183
|
+
if (!univer) return;
|
|
184
|
+
const injector = (univer as { _injector?: { get(t: unknown): unknown } })._injector;
|
|
185
|
+
const cmd = injector?.get(ICommandService) as
|
|
186
|
+
| {
|
|
187
|
+
onCommandExecuted: (cb: (info: ICommandInfo, options?: unknown) => void) => {
|
|
188
|
+
dispose: () => void;
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
| undefined;
|
|
192
|
+
if (!cmd) return;
|
|
193
|
+
|
|
194
|
+
const sub = cmd.onCommandExecuted((info) => {
|
|
195
|
+
// Only intent-level COMMANDs; mutations/operations would spam the feed.
|
|
196
|
+
// `type` is optional on ICommandInfo but the dispatcher populates it —
|
|
197
|
+
// treat an explicit non-COMMAND type as a reject, and let undefined
|
|
198
|
+
// fall through to the id-based allow-list below.
|
|
199
|
+
if (info.type !== undefined && info.type !== CommandType.COMMAND) return;
|
|
200
|
+
if (isNoise(info.id)) return;
|
|
201
|
+
// Ignore anything not on the allow-list unless it looks like a real
|
|
202
|
+
// sheet command (keeps the feed to genuine edits, not internal churn).
|
|
203
|
+
if (!COMMAND_LABELS[info.id] && !info.id.startsWith('sheet.command.')) return;
|
|
204
|
+
|
|
205
|
+
const { label, icon } = labelFor(info.id);
|
|
206
|
+
const seq = ++seqRef.current;
|
|
207
|
+
const entry: ActivityEntry = { seq, id: info.id, label, icon, t: Date.now() };
|
|
208
|
+
setEntries((prev) => {
|
|
209
|
+
const next = prev.length >= RING_CAP ? prev.slice(prev.length - RING_CAP + 1) : prev;
|
|
210
|
+
return [...next, entry];
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
return () => sub.dispose();
|
|
214
|
+
}, [univer]);
|
|
215
|
+
|
|
216
|
+
// Refresh relative timestamps once a minute while the panel is open.
|
|
217
|
+
useEffect(() => {
|
|
218
|
+
const h = setInterval(() => setNowTick((n) => n + 1), 60_000);
|
|
219
|
+
return () => clearInterval(h);
|
|
220
|
+
}, []);
|
|
221
|
+
|
|
222
|
+
// Newest-first. Ordering is purely by seq — the render-time `now` only
|
|
223
|
+
// affects the human-readable age string, never the order.
|
|
224
|
+
const sorted = useMemo(() => [...entries].sort((a, b) => b.seq - a.seq), [entries]);
|
|
225
|
+
const now = useMemo(() => Date.now(), [nowTick, entries.length]);
|
|
226
|
+
|
|
227
|
+
const empty = sorted.length === 0;
|
|
228
|
+
|
|
229
|
+
return (
|
|
230
|
+
<div
|
|
231
|
+
data-testid="cs-history-panel"
|
|
232
|
+
style={{ height: '100%', display: 'flex', flexDirection: 'column' }}
|
|
233
|
+
>
|
|
234
|
+
<PanelHeader icon="history" title="Activity" count={sorted.length} onClose={onClose} />
|
|
235
|
+
|
|
236
|
+
<div style={{ flex: 1, overflow: 'auto', padding: 12 }}>
|
|
237
|
+
{empty ? (
|
|
238
|
+
<div
|
|
239
|
+
data-testid="cs-history-panel-empty"
|
|
240
|
+
style={{ textAlign: 'center', opacity: 0.75, padding: '24px 8px' }}
|
|
241
|
+
>
|
|
242
|
+
<Icon name="history" size={40} style={{ opacity: 0.4 }} />
|
|
243
|
+
<div style={{ fontWeight: 600, marginTop: 8 }}>No activity yet</div>
|
|
244
|
+
<div style={{ fontSize: 13, marginTop: 4 }}>
|
|
245
|
+
Edits you make this session show up here.
|
|
246
|
+
</div>
|
|
247
|
+
</div>
|
|
248
|
+
) : (
|
|
249
|
+
<ol
|
|
250
|
+
role="list"
|
|
251
|
+
style={{
|
|
252
|
+
listStyle: 'none',
|
|
253
|
+
margin: 0,
|
|
254
|
+
padding: 0,
|
|
255
|
+
display: 'flex',
|
|
256
|
+
flexDirection: 'column',
|
|
257
|
+
gap: 2,
|
|
258
|
+
}}
|
|
259
|
+
>
|
|
260
|
+
{sorted.map((e) => (
|
|
261
|
+
<li
|
|
262
|
+
key={e.seq}
|
|
263
|
+
data-testid="cs-history-row"
|
|
264
|
+
style={{
|
|
265
|
+
display: 'flex',
|
|
266
|
+
alignItems: 'center',
|
|
267
|
+
gap: 10,
|
|
268
|
+
padding: '8px 8px',
|
|
269
|
+
borderRadius: 8,
|
|
270
|
+
}}
|
|
271
|
+
>
|
|
272
|
+
<span
|
|
273
|
+
aria-hidden
|
|
274
|
+
style={{
|
|
275
|
+
display: 'inline-flex',
|
|
276
|
+
alignItems: 'center',
|
|
277
|
+
justifyContent: 'center',
|
|
278
|
+
width: 28,
|
|
279
|
+
height: 28,
|
|
280
|
+
flex: '0 0 auto',
|
|
281
|
+
borderRadius: 6,
|
|
282
|
+
background: 'var(--cs-chrome-subtle-bg, #f4f5f8)',
|
|
283
|
+
opacity: 0.9,
|
|
284
|
+
}}
|
|
285
|
+
>
|
|
286
|
+
<Icon name={e.icon} size={16} />
|
|
287
|
+
</span>
|
|
288
|
+
<span style={{ flex: 1, minWidth: 0, fontSize: 13, fontWeight: 500 }}>
|
|
289
|
+
{e.label}
|
|
290
|
+
</span>
|
|
291
|
+
<time
|
|
292
|
+
dateTime={new Date(e.t).toISOString()}
|
|
293
|
+
style={{ opacity: 0.6, fontSize: 12, whiteSpace: 'nowrap' }}
|
|
294
|
+
>
|
|
295
|
+
{relativeTime(e.t, now)}
|
|
296
|
+
</time>
|
|
297
|
+
</li>
|
|
298
|
+
))}
|
|
299
|
+
</ol>
|
|
300
|
+
)}
|
|
301
|
+
</div>
|
|
302
|
+
</div>
|
|
303
|
+
);
|
|
304
|
+
}
|
|
@@ -49,8 +49,10 @@
|
|
|
49
49
|
*/
|
|
50
50
|
|
|
51
51
|
import { useMemo, useState, type CSSProperties } from 'react';
|
|
52
|
+
import type { IWorkbookData } from '@univerjs/core';
|
|
52
53
|
import type { DialogComponentProps } from './extensions';
|
|
53
54
|
import type { CasualSheetsAPI } from '../sheets/api';
|
|
55
|
+
import type { PivotModel } from '../pivots/types';
|
|
54
56
|
import { Dialog } from './Dialog';
|
|
55
57
|
import {
|
|
56
58
|
DIALOG_BTN_PRIMARY_STYLE,
|
|
@@ -86,11 +88,15 @@ function activeRange(api: CasualSheetsAPI) {
|
|
|
86
88
|
interface RangeView {
|
|
87
89
|
getValues: () => Cell[][];
|
|
88
90
|
getA1Notation?: () => string;
|
|
91
|
+
/** Top-left cell coords (0-based) — used to anchor the pivot's source range. */
|
|
92
|
+
getRow?: () => number;
|
|
93
|
+
getColumn?: () => number;
|
|
89
94
|
}
|
|
90
95
|
|
|
91
96
|
/** Loosely-typed FWorksheet view — sheet name + a range handle by A1. */
|
|
92
97
|
interface SheetView {
|
|
93
98
|
getSheetName: () => string;
|
|
99
|
+
getSheetId?: () => string;
|
|
94
100
|
getRange: (a1: string) => RangeView & { setValues: (v: Cell[][]) => unknown };
|
|
95
101
|
}
|
|
96
102
|
|
|
@@ -205,6 +211,35 @@ function toA1(row: number, col: number): string {
|
|
|
205
211
|
return `${letters}${row + 1}`;
|
|
206
212
|
}
|
|
207
213
|
|
|
214
|
+
/** Resource envelope the pivot models live in — matches PivotFieldsPanel so the
|
|
215
|
+
* panel edits the same pivot the dialog created. */
|
|
216
|
+
const PIVOTS_RESOURCE_NAME = '__casual_sheets_pivots__';
|
|
217
|
+
|
|
218
|
+
/** Append a freshly-created pivot model to the workbook snapshot resource. */
|
|
219
|
+
function persistPivotModel(api: CasualSheetsAPI, model: PivotModel): void {
|
|
220
|
+
const data = api.getContent();
|
|
221
|
+
if (!data) return;
|
|
222
|
+
const resources = data.resources ? [...data.resources] : [];
|
|
223
|
+
const idx = resources.findIndex((r) => r.name === PIVOTS_RESOURCE_NAME);
|
|
224
|
+
let pivots: PivotModel[] = [];
|
|
225
|
+
if (idx >= 0 && resources[idx]?.data) {
|
|
226
|
+
try {
|
|
227
|
+
const parsed = JSON.parse(resources[idx].data) as { v?: number; pivots?: PivotModel[] };
|
|
228
|
+
if (parsed?.v === 1 && Array.isArray(parsed.pivots)) pivots = parsed.pivots;
|
|
229
|
+
} catch {
|
|
230
|
+
pivots = [];
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
const merged = pivots.some((p) => p.id === model.id)
|
|
234
|
+
? pivots.map((p) => (p.id === model.id ? model : p))
|
|
235
|
+
: [...pivots, model];
|
|
236
|
+
const entry = { name: PIVOTS_RESOURCE_NAME, data: JSON.stringify({ v: 1, pivots: merged }) };
|
|
237
|
+
if (idx >= 0) resources[idx] = entry;
|
|
238
|
+
else resources.push(entry);
|
|
239
|
+
const nextData: IWorkbookData = { ...data, resources };
|
|
240
|
+
api.setContent(nextData);
|
|
241
|
+
}
|
|
242
|
+
|
|
208
243
|
interface DialogState {
|
|
209
244
|
useHeader: boolean;
|
|
210
245
|
groupCol: number;
|
|
@@ -288,6 +323,23 @@ export function InsertPivotDialog({ api, onClose }: DialogComponentProps) {
|
|
|
288
323
|
return;
|
|
289
324
|
}
|
|
290
325
|
|
|
326
|
+
// Source range coords (absolute) — the pivot model reads its data from here,
|
|
327
|
+
// and the Fields panel re-applies against the same range on every edit.
|
|
328
|
+
const srcRange = activeRange(api) as unknown as RangeView | null;
|
|
329
|
+
const activeWs = wb.getActiveSheet() as unknown as SheetView | null;
|
|
330
|
+
const srcRow = srcRange?.getRow?.() ?? 0;
|
|
331
|
+
const srcCol = srcRange?.getColumn?.() ?? 0;
|
|
332
|
+
const sourceSheetId = activeWs?.getSheetId?.() ?? '';
|
|
333
|
+
const sourceRange = {
|
|
334
|
+
startRow: srcRow,
|
|
335
|
+
startColumn: srcCol,
|
|
336
|
+
endRow: srcRow + source.length - 1,
|
|
337
|
+
endColumn: srcCol + (source[0]?.length ?? 1) - 1,
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
let targetSheetId = sourceSheetId;
|
|
341
|
+
let target = { row: 0, column: 0 };
|
|
342
|
+
|
|
291
343
|
try {
|
|
292
344
|
if (state.destination === 'newSheet') {
|
|
293
345
|
const name = state.sheetName.trim() || 'Pivot';
|
|
@@ -296,8 +348,9 @@ export function InsertPivotDialog({ api, onClose }: DialogComponentProps) {
|
|
|
296
348
|
const sheet = (
|
|
297
349
|
wb as unknown as { create: (n: string, r: number, c: number) => SheetView }
|
|
298
350
|
).create(name, rows, 4);
|
|
299
|
-
|
|
300
|
-
|
|
351
|
+
sheet.getRange(`A1:${toA1(pivot.grid.length - 1, 1)}`).setValues(pivot.grid);
|
|
352
|
+
targetSheetId = sheet.getSheetId?.() ?? sourceSheetId;
|
|
353
|
+
target = { row: 0, column: 0 };
|
|
301
354
|
} else {
|
|
302
355
|
const anchor = state.anchor.trim().toUpperCase();
|
|
303
356
|
if (!/^[A-Z]+[0-9]+$/.test(anchor)) {
|
|
@@ -317,12 +370,31 @@ export function InsertPivotDialog({ api, onClose }: DialogComponentProps) {
|
|
|
317
370
|
const startRow = Number(m[2]) - 1;
|
|
318
371
|
const end = toA1(startRow + pivot.grid.length - 1, col + 1);
|
|
319
372
|
sheet.getRange(`${anchor}:${end}`).setValues(pivot.grid);
|
|
373
|
+
target = { row: startRow, column: col };
|
|
320
374
|
}
|
|
321
375
|
} catch (e) {
|
|
322
376
|
setError(e instanceof Error ? e.message : 'Failed to create the pivot table.');
|
|
323
377
|
return;
|
|
324
378
|
}
|
|
325
379
|
|
|
380
|
+
// Persist an editable model (header-based pivots only — the model treats the
|
|
381
|
+
// source's first row as headers) so the Fields panel can reconfigure it and
|
|
382
|
+
// the engine recomputes the output grid on every edit.
|
|
383
|
+
if (state.useHeader && sourceSheetId && targetSheetId) {
|
|
384
|
+
persistPivotModel(api, {
|
|
385
|
+
id: `pivot-${targetSheetId}-${target.row}-${target.column}`,
|
|
386
|
+
sourceSheetId,
|
|
387
|
+
source: sourceRange,
|
|
388
|
+
targetSheetId,
|
|
389
|
+
target,
|
|
390
|
+
rows: [{ column: state.groupCol }],
|
|
391
|
+
cols: [],
|
|
392
|
+
values: [{ column: state.valueCol, agg: state.aggFn }],
|
|
393
|
+
filters: [],
|
|
394
|
+
lastOutputExtent: { rows: pivot.grid.length, cols: pivot.grid[0]?.length ?? 0 },
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
|
|
326
398
|
onClose();
|
|
327
399
|
};
|
|
328
400
|
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright 2026 Casual Office
|
|
3
|
+
*
|
|
4
|
+
* Licensed under the Apache License, Version 2.0 (the "License").
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Renders the currently-open side panel's body (built-in or host-supplied),
|
|
9
|
+
* to the left of the rail. Returns null when no panel is open so the grid keeps
|
|
10
|
+
* the full width. Each panel body receives `{ api, onClose }`.
|
|
11
|
+
*/
|
|
12
|
+
import { Suspense, type CSSProperties } from 'react';
|
|
13
|
+
|
|
14
|
+
import type { CasualSheetsAPI } from '../sheets/api';
|
|
15
|
+
import type { ChromeExtensions } from './extensions';
|
|
16
|
+
import { usePanels } from './panel-context';
|
|
17
|
+
import { BUILT_IN_PANELS } from './panel-registry';
|
|
18
|
+
|
|
19
|
+
const asideStyle: CSSProperties = {
|
|
20
|
+
flex: '0 0 auto',
|
|
21
|
+
width: 320,
|
|
22
|
+
minWidth: 0,
|
|
23
|
+
height: '100%',
|
|
24
|
+
overflow: 'auto',
|
|
25
|
+
borderLeft: '1px solid var(--color-divider, var(--cs-chrome-border, #edeff3))',
|
|
26
|
+
background: 'var(--color-surface-alt, var(--cs-chrome-input-bg, #ffffff))',
|
|
27
|
+
color: 'var(--color-text, var(--cs-chrome-fg, #201f1e))',
|
|
28
|
+
fontSize: 13,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export function PanelHost({
|
|
32
|
+
api,
|
|
33
|
+
extensions,
|
|
34
|
+
}: {
|
|
35
|
+
api: CasualSheetsAPI | null;
|
|
36
|
+
extensions?: ChromeExtensions;
|
|
37
|
+
}) {
|
|
38
|
+
const panels = usePanels();
|
|
39
|
+
const openId = panels.openPanelId;
|
|
40
|
+
if (!openId || !api) return null;
|
|
41
|
+
|
|
42
|
+
const builtIn = BUILT_IN_PANELS.find((p) => p.id === openId);
|
|
43
|
+
const host = extensions?.panels?.find((p) => p.id === openId);
|
|
44
|
+
const Body = builtIn?.component ?? host?.component;
|
|
45
|
+
if (!Body) return null;
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<aside style={asideStyle} data-testid={`cs-panel-${openId}`}>
|
|
49
|
+
{/* Panels may be lazy (e.g. Charts pulls echarts) — Suspense keeps the
|
|
50
|
+
rest of the chrome painted while the chunk loads. */}
|
|
51
|
+
<Suspense fallback={null}>
|
|
52
|
+
<Body api={api} onClose={panels.close} />
|
|
53
|
+
</Suspense>
|
|
54
|
+
</aside>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright 2026 Casual Office
|
|
3
|
+
*
|
|
4
|
+
* Licensed under the Apache License, Version 2.0 (the "License").
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Right-edge vertical rail of panel-toggle buttons (mirrors the standalone
|
|
9
|
+
* app's PanelRail, but registry-driven and self-contained in the SDK). Built-in
|
|
10
|
+
* panels come first, then any host `extensions.panels`. Clicking a button
|
|
11
|
+
* toggles that panel through the shared panel store; the open panel's body is
|
|
12
|
+
* rendered to the left by PanelHost.
|
|
13
|
+
*/
|
|
14
|
+
import type { CSSProperties } from 'react';
|
|
15
|
+
|
|
16
|
+
import type { ChromeExtensions } from './extensions';
|
|
17
|
+
import { Icon } from './Icon';
|
|
18
|
+
import { usePanels } from './panel-context';
|
|
19
|
+
import { BUILT_IN_PANELS } from './panel-registry';
|
|
20
|
+
|
|
21
|
+
const railStyle: CSSProperties = {
|
|
22
|
+
display: 'flex',
|
|
23
|
+
flexDirection: 'column',
|
|
24
|
+
alignItems: 'center',
|
|
25
|
+
gap: 2,
|
|
26
|
+
padding: '6px 4px',
|
|
27
|
+
flex: '0 0 auto',
|
|
28
|
+
borderLeft: '1px solid var(--cs-chrome-border, #edeff3)',
|
|
29
|
+
background: 'var(--cs-chrome-bg, #eef1f5)',
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export interface RailEntry {
|
|
33
|
+
id: string;
|
|
34
|
+
label: string;
|
|
35
|
+
icon: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Merge built-in panels with host-supplied ones into a single rail list. */
|
|
39
|
+
export function railEntries(extensions?: ChromeExtensions): RailEntry[] {
|
|
40
|
+
const built = BUILT_IN_PANELS.map((p) => ({ id: p.id, label: p.label, icon: p.icon }));
|
|
41
|
+
const host = (extensions?.panels ?? []).map((p) => ({
|
|
42
|
+
id: p.id,
|
|
43
|
+
label: p.title,
|
|
44
|
+
icon: p.railIcon,
|
|
45
|
+
}));
|
|
46
|
+
return [...built, ...host];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function PanelRail({ extensions }: { extensions?: ChromeExtensions }) {
|
|
50
|
+
const panels = usePanels();
|
|
51
|
+
const entries = railEntries(extensions);
|
|
52
|
+
if (entries.length === 0) return null;
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<aside style={railStyle} data-testid="cs-panel-rail" aria-label="Panels" role="toolbar">
|
|
56
|
+
{entries.map((e) => {
|
|
57
|
+
const pressed = panels.openPanelId === e.id;
|
|
58
|
+
return (
|
|
59
|
+
<button
|
|
60
|
+
key={e.id}
|
|
61
|
+
type="button"
|
|
62
|
+
data-testid={`cs-panel-rail-${e.id}`}
|
|
63
|
+
aria-pressed={pressed}
|
|
64
|
+
aria-label={pressed ? `Hide ${e.label}` : e.label}
|
|
65
|
+
title={pressed ? `Hide ${e.label}` : e.label}
|
|
66
|
+
onClick={() => panels.toggle(e.id)}
|
|
67
|
+
style={{
|
|
68
|
+
display: 'flex',
|
|
69
|
+
alignItems: 'center',
|
|
70
|
+
justifyContent: 'center',
|
|
71
|
+
width: 30,
|
|
72
|
+
height: 30,
|
|
73
|
+
border: 'none',
|
|
74
|
+
borderRadius: 6,
|
|
75
|
+
cursor: 'pointer',
|
|
76
|
+
color: pressed
|
|
77
|
+
? 'var(--cs-chrome-active-fg, #0e7490)'
|
|
78
|
+
: 'var(--cs-chrome-fg, #201f1e)',
|
|
79
|
+
background: pressed
|
|
80
|
+
? 'var(--cs-chrome-active, rgba(14,116,144,0.11))'
|
|
81
|
+
: 'transparent',
|
|
82
|
+
}}
|
|
83
|
+
>
|
|
84
|
+
<Icon name={e.icon} size={18} />
|
|
85
|
+
</button>
|
|
86
|
+
);
|
|
87
|
+
})}
|
|
88
|
+
</aside>
|
|
89
|
+
);
|
|
90
|
+
}
|