@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,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
- const target = sheet.getRange(`A1:${toA1(pivot.grid.length - 1, 1)}`);
300
- target.setValues(pivot.grid);
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
+ }