@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,293 @@
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 { useCallback, useEffect, useRef, useState } from 'react';
18
+ import { useCharts } from './charts-context';
19
+ import { init, type EChartsType } from './echarts-init';
20
+ import { buildEChartsOption } from './build-option';
21
+ import { rectToCellPos } from './hit-test';
22
+ import type { ChartModel } from './types';
23
+
24
+ /**
25
+ * Single chart rendered on screen. Responsibilities:
26
+ *
27
+ * - Owns one ECharts instance, mounted into a dedicated div.
28
+ * - Reads source data on mount + on every workbook value change,
29
+ * redraws via `setOption(option, true)`.
30
+ * - Selection: click selects (frame + 8 resize handles appear);
31
+ * the parent (ChartLayer) handles click-outside-to-deselect.
32
+ * - Drag-to-move: pointer-down on the body, drag, release → snap
33
+ * the model.pos top-left to the nearest cell, keep the size.
34
+ * - Drag-to-resize: pointer-down on any of the 8 handles, drag,
35
+ * release → snap both anchor corners to cells.
36
+ *
37
+ * Excel parity notes:
38
+ * - 4 corner handles (resize both axes) + 4 mid-edge handles
39
+ * (resize one axis).
40
+ * - Cursor changes to nwse/nesw/ns/ew depending on handle.
41
+ * - Move/resize during drag is free-positioned via CSS; the cell
42
+ * anchor only updates on pointer-up (so the model stays in cell
43
+ * coordinates and round-trips cleanly through xlsx + collab).
44
+ */
45
+ type Props = {
46
+ model: ChartModel;
47
+ /** Host-local CSS box. Parent computes from the cell rect + the
48
+ * active sheet's scroll offset on every animation frame. */
49
+ rect: { left: number; top: number; width: number; height: number };
50
+ /** Canvas-vs-host offset + current scroll. Needed to convert the
51
+ * chart's screen pixels back to canvas-local pre-scroll coords
52
+ * when snapping to cells on drop. */
53
+ canvasOffset: { x: number; y: number };
54
+ scroll: { x: number; y: number };
55
+ };
56
+
57
+ type Handle = 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w' | 'nw';
58
+
59
+ const CORNER_HANDLES: Handle[] = ['nw', 'ne', 'se', 'sw'];
60
+ const EDGE_HANDLES: Handle[] = ['n', 'e', 's', 'w'];
61
+ const ALL_HANDLES: Handle[] = [...CORNER_HANDLES, ...EDGE_HANDLES];
62
+
63
+ const HANDLE_CURSORS: Record<Handle, string> = {
64
+ n: 'ns-resize',
65
+ s: 'ns-resize',
66
+ e: 'ew-resize',
67
+ w: 'ew-resize',
68
+ ne: 'nesw-resize',
69
+ sw: 'nesw-resize',
70
+ nw: 'nwse-resize',
71
+ se: 'nwse-resize',
72
+ };
73
+
74
+ const MIN_W = 80;
75
+ const MIN_H = 60;
76
+
77
+ export function ChartOverlay({ model, rect, canvasOffset, scroll }: Props) {
78
+ const { api: sdkApi, selectedId, select, update } = useCharts();
79
+ // FUniver facade — reached through the SDK handle.
80
+ const api = sdkApi.univer;
81
+ const hostRef = useRef<HTMLDivElement>(null);
82
+ const echartRef = useRef<EChartsType | null>(null);
83
+ const isSelected = selectedId === model.id;
84
+
85
+ // `drag` is the ephemeral pixel-space delta applied while the user
86
+ // is dragging the body or a handle. It's a CSS-only transform until
87
+ // pointer-up, at which point we compute the new cell anchor and
88
+ // commit via `update()`. Null when not dragging.
89
+ const [drag, setDrag] = useState<null | {
90
+ mode: 'move' | Handle;
91
+ startMouse: { x: number; y: number };
92
+ startRect: { left: number; top: number; width: number; height: number };
93
+ current: { left: number; top: number; width: number; height: number };
94
+ }>(null);
95
+
96
+ // Init ECharts once + dispose on unmount. ECharts mutates its own
97
+ // DOM children so it must own its container.
98
+ useEffect(() => {
99
+ const host = hostRef.current;
100
+ if (!host) return;
101
+ const inst = init(host, undefined, { renderer: 'canvas' });
102
+ echartRef.current = inst;
103
+ return () => {
104
+ inst.dispose();
105
+ echartRef.current = null;
106
+ };
107
+ }, []);
108
+
109
+ // Resize whenever the cell-rect-derived box (or the ephemeral drag
110
+ // rect) changes. ECharts won't auto-resize on its own.
111
+ useEffect(() => {
112
+ echartRef.current?.resize();
113
+ }, [rect.width, rect.height, drag?.current.width, drag?.current.height]);
114
+
115
+ useEffect(() => {
116
+ if (!api) return;
117
+ const refresh = () => {
118
+ const opt = buildEChartsOption(api, model);
119
+ if (opt) {
120
+ echartRef.current?.setOption(opt, true);
121
+ // Expose the resolved ECharts option per chart id so e2e specs
122
+ // (and manual debugging) can assert on the live config — e.g.
123
+ // dual-axis charts emit a two-entry `yAxis`. Mirrors the
124
+ // `window.__univerAPI` test hook; carries no secrets.
125
+ const w = window as unknown as {
126
+ __casualChartOptions?: Record<string, unknown>;
127
+ };
128
+ w.__casualChartOptions = w.__casualChartOptions ?? {};
129
+ w.__casualChartOptions[model.id] = opt;
130
+ }
131
+ };
132
+ refresh();
133
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
134
+ const evName = (api as any).Event?.SheetValueChanged;
135
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
136
+ if (!evName || typeof (api as any).addEvent !== 'function') return;
137
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
138
+ const disp = (api as any).addEvent(evName, refresh) as { dispose?: () => void };
139
+ return () => disp.dispose?.();
140
+ }, [api, model]);
141
+
142
+ // Pointer-up handler installed at the document level while a drag
143
+ // is in flight. We can't put it on the overlay because the pointer
144
+ // can escape during the move (and Excel doesn't lock pointers either).
145
+ const commitDrag = useCallback(() => {
146
+ if (!drag || !api) return;
147
+ const wb = api.getActiveWorkbook();
148
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
149
+ const sheets = wb?.getSheets() as any[] | undefined;
150
+ const ws = sheets?.find((s) => s.getSheetId?.() === model.sheetId);
151
+ if (!ws) {
152
+ setDrag(null);
153
+ return;
154
+ }
155
+ const newPos = rectToCellPos(ws, drag.current, canvasOffset, scroll, {
156
+ startRow: model.pos.startRow,
157
+ startColumn: model.pos.startColumn,
158
+ endRow: model.pos.endRow,
159
+ endColumn: model.pos.endColumn,
160
+ });
161
+ if (newPos) update(model.id, { pos: newPos });
162
+ setDrag(null);
163
+ }, [api, canvasOffset, drag, model, scroll, update]);
164
+
165
+ useEffect(() => {
166
+ if (!drag) return;
167
+ const onMove = (e: PointerEvent) => {
168
+ const dx = e.clientX - drag.startMouse.x;
169
+ const dy = e.clientY - drag.startMouse.y;
170
+ setDrag((cur) => {
171
+ if (!cur) return cur;
172
+ const next = computeDragRect(cur.mode, cur.startRect, dx, dy);
173
+ return { ...cur, current: next };
174
+ });
175
+ };
176
+ const onUp = () => commitDrag();
177
+ document.addEventListener('pointermove', onMove);
178
+ document.addEventListener('pointerup', onUp);
179
+ document.addEventListener('pointercancel', onUp);
180
+ return () => {
181
+ document.removeEventListener('pointermove', onMove);
182
+ document.removeEventListener('pointerup', onUp);
183
+ document.removeEventListener('pointercancel', onUp);
184
+ };
185
+ }, [drag, commitDrag]);
186
+
187
+ const onBodyPointerDown = (e: React.PointerEvent) => {
188
+ if (e.button !== 0) return; // primary button only
189
+ e.stopPropagation();
190
+ select(model.id);
191
+ // A bare click selects but doesn't start a drag — the drag effect
192
+ // only commits on pointer-up if `drag.current` differs from
193
+ // `drag.startRect`. So we always set drag and trust commit logic.
194
+ setDrag({
195
+ mode: 'move',
196
+ startMouse: { x: e.clientX, y: e.clientY },
197
+ startRect: { ...rect },
198
+ current: { ...rect },
199
+ });
200
+ };
201
+
202
+ const onHandlePointerDown = (handle: Handle) => (e: React.PointerEvent) => {
203
+ if (e.button !== 0) return;
204
+ e.stopPropagation();
205
+ select(model.id);
206
+ setDrag({
207
+ mode: handle,
208
+ startMouse: { x: e.clientX, y: e.clientY },
209
+ startRect: { ...rect },
210
+ current: { ...rect },
211
+ });
212
+ };
213
+
214
+ const liveRect = drag?.current ?? rect;
215
+
216
+ return (
217
+ <div
218
+ ref={hostRef}
219
+ className={`chart-overlay${isSelected ? ' chart-overlay--selected' : ''}${drag ? ' chart-overlay--dragging' : ''}`}
220
+ data-testid="chart-overlay"
221
+ data-chart-id={model.id}
222
+ data-selected={isSelected ? 'true' : undefined}
223
+ style={{
224
+ position: 'absolute',
225
+ left: `${liveRect.left}px`,
226
+ top: `${liveRect.top}px`,
227
+ width: `${liveRect.width}px`,
228
+ height: `${liveRect.height}px`,
229
+ cursor: drag?.mode === 'move' ? 'grabbing' : isSelected ? 'grab' : 'pointer',
230
+ }}
231
+ onPointerDown={onBodyPointerDown}
232
+ onContextMenu={(e) => {
233
+ e.preventDefault();
234
+ select(model.id);
235
+ // ChartLayer listens for this event and pops a context menu —
236
+ // we just need to ensure the chart is selected first.
237
+ const ce = new CustomEvent('casual-chart-contextmenu', {
238
+ detail: { id: model.id, x: e.clientX, y: e.clientY },
239
+ });
240
+ document.dispatchEvent(ce);
241
+ }}
242
+ >
243
+ {isSelected &&
244
+ ALL_HANDLES.map((h) => (
245
+ <div
246
+ key={h}
247
+ data-testid={`chart-handle-${h}`}
248
+ className={`chart-overlay__handle chart-overlay__handle--${h}`}
249
+ style={{ cursor: HANDLE_CURSORS[h] }}
250
+ onPointerDown={onHandlePointerDown(h)}
251
+ />
252
+ ))}
253
+ </div>
254
+ );
255
+ }
256
+
257
+ /**
258
+ * Apply a drag delta to the starting rect based on which handle (or
259
+ * the body) is being dragged. Mode `'move'` slides the rect; the
260
+ * handle modes resize from the matching edge / corner. We clamp
261
+ * each dimension to a minimum so the chart can't collapse onto a
262
+ * point (impossible to grab again).
263
+ */
264
+ function computeDragRect(
265
+ mode: 'move' | Handle,
266
+ start: { left: number; top: number; width: number; height: number },
267
+ dx: number,
268
+ dy: number,
269
+ ): { left: number; top: number; width: number; height: number } {
270
+ if (mode === 'move') {
271
+ return { left: start.left + dx, top: start.top + dy, width: start.width, height: start.height };
272
+ }
273
+ let { left, top, width, height } = start;
274
+ const wantsLeft = mode === 'nw' || mode === 'w' || mode === 'sw';
275
+ const wantsRight = mode === 'ne' || mode === 'e' || mode === 'se';
276
+ const wantsTop = mode === 'nw' || mode === 'n' || mode === 'ne';
277
+ const wantsBottom = mode === 'sw' || mode === 's' || mode === 'se';
278
+ if (wantsLeft) {
279
+ const newWidth = Math.max(MIN_W, start.width - dx);
280
+ left = start.left + (start.width - newWidth);
281
+ width = newWidth;
282
+ } else if (wantsRight) {
283
+ width = Math.max(MIN_W, start.width + dx);
284
+ }
285
+ if (wantsTop) {
286
+ const newHeight = Math.max(MIN_H, start.height - dy);
287
+ top = start.top + (start.height - newHeight);
288
+ height = newHeight;
289
+ } else if (wantsBottom) {
290
+ height = Math.max(MIN_H, start.height + dy);
291
+ }
292
+ return { left, top, width, height };
293
+ }
@@ -0,0 +1,232 @@
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 { useMemo, useState } from 'react';
18
+ import type { PanelComponentProps } from '../chrome/extensions';
19
+ import { Icon } from '../chrome/Icon';
20
+ import { PanelHeader, PanelEmpty, IconButton } from '../chrome/panel-shell';
21
+
22
+ const MUTED = 'var(--color-text-secondary, var(--cs-chrome-muted, #605e5c))';
23
+ const DIVIDER = 'var(--color-divider, var(--cs-chrome-border, #edeff3))';
24
+ const rowStyle = {
25
+ border: `1px solid ${DIVIDER}`,
26
+ borderRadius: 8,
27
+ padding: 10,
28
+ display: 'flex',
29
+ flexDirection: 'column' as const,
30
+ gap: 6,
31
+ };
32
+ const nameBtnStyle = {
33
+ flex: 1,
34
+ textAlign: 'left' as const,
35
+ border: 'none',
36
+ background: 'transparent',
37
+ cursor: 'pointer',
38
+ font: 'inherit',
39
+ fontWeight: 600,
40
+ color: 'inherit',
41
+ };
42
+ const ctaStyle = {
43
+ border: `1px solid ${DIVIDER}`,
44
+ borderRadius: 6,
45
+ padding: '6px 12px',
46
+ cursor: 'pointer',
47
+ font: 'inherit',
48
+ fontWeight: 600,
49
+ display: 'inline-flex',
50
+ alignItems: 'center',
51
+ gap: 6,
52
+ background: 'var(--color-surface, var(--cs-chrome-input-bg, #fff))',
53
+ color: 'var(--color-accent, var(--cs-chrome-active-fg, #0e7490))',
54
+ };
55
+ import { useCharts } from './charts-context';
56
+ import { getActiveSelectionRange, rangeToA1, buildChartModelForRange } from './insert-chart';
57
+ import { nextChartName } from './naming';
58
+ import { InsertChartDialog } from './InsertChartDialog';
59
+ import { CHART_FAMILY_OF, CHART_TYPE_LABEL, type ChartFamily, type ChartModel } from './types';
60
+
61
+ /**
62
+ * Right-side Charts panel. Equivalent of Excel's Selection Pane scoped
63
+ * to charts on the active sheet: list every chart, click the name to
64
+ * rename, click the source-range badge to flash that range in the grid,
65
+ * delete from the row, and "Insert chart" from the empty-state CTA.
66
+ *
67
+ * Only charts on the active sheet are shown — same scoping Excel uses
68
+ * for its Selection Pane (it switches with the active sheet tab).
69
+ *
70
+ * SDK port: this is a chrome side-panel (`{ api, onClose }`). The app's
71
+ * `useUI().toggleChartsPanel` becomes the panel host's `onClose`; the
72
+ * FUniver facade is reached through `api.univer`. Chart data still comes
73
+ * from `useCharts()` (mount `<ChartsProvider api={...}>` above the chrome).
74
+ */
75
+ const FAMILY_ICONS: Record<ChartFamily, string> = {
76
+ column: 'bar_chart',
77
+ bar: 'align_horizontal_left',
78
+ line: 'show_chart',
79
+ area: 'area_chart',
80
+ pie: 'pie_chart',
81
+ scatter: 'scatter_plot',
82
+ };
83
+
84
+ export function ChartsPanel({ api, onClose }: PanelComponentProps) {
85
+ // FUniver facade — reached through the SDK handle.
86
+ const univer = api.univer;
87
+ const { charts, insert, remove, update } = useCharts();
88
+ const [renaming, setRenaming] = useState<{ id: string; draft: string } | null>(null);
89
+ const [showInsert, setShowInsert] = useState(false);
90
+ const [insertDefault, setInsertDefault] = useState('A1');
91
+
92
+ const activeSheetId = useMemo(() => {
93
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
94
+ const ws: any = univer?.getActiveWorkbook?.()?.getActiveSheet();
95
+ return ws?.getSheetId?.() ?? null;
96
+ }, [univer, charts]);
97
+
98
+ const visible = useMemo<ChartModel[]>(
99
+ () => (activeSheetId ? charts.filter((c) => c.sheetId === activeSheetId) : charts),
100
+ [charts, activeSheetId],
101
+ );
102
+ const empty = visible.length === 0;
103
+
104
+ const onRenameCommit = (id: string, prev: string) => {
105
+ if (!renaming || renaming.id !== id) return;
106
+ const draft = renaming.draft.trim();
107
+ if (!draft || draft === prev) {
108
+ setRenaming(null);
109
+ return;
110
+ }
111
+ update(id, { title: draft });
112
+ setRenaming(null);
113
+ };
114
+
115
+ const openInsert = () => {
116
+ if (!univer) return;
117
+ const sel = getActiveSelectionRange(univer);
118
+ setInsertDefault(sel ? rangeToA1(sel) : 'A1');
119
+ setShowInsert(true);
120
+ };
121
+
122
+ return (
123
+ <div data-testid="charts-panel" style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
124
+ <PanelHeader icon="bar_chart" title="Charts" count={visible.length} onClose={onClose} />
125
+ <div style={{ flex: 1, overflow: 'auto', padding: 12 }}>
126
+ {empty ? (
127
+ <PanelEmpty icon="bar_chart" title="No charts on this sheet" testId="charts-panel-empty">
128
+ Select the data range you want to plot, then use <strong>Insert → Chart</strong> — or:
129
+ <div style={{ marginTop: 12 }}>
130
+ <button
131
+ type="button"
132
+ data-testid="charts-panel-empty-cta"
133
+ disabled={!univer}
134
+ onClick={openInsert}
135
+ style={ctaStyle}
136
+ >
137
+ <Icon name="add" size={16} /> Insert chart
138
+ </button>
139
+ </div>
140
+ </PanelEmpty>
141
+ ) : (
142
+ <ul
143
+ style={{
144
+ listStyle: 'none',
145
+ margin: 0,
146
+ padding: 0,
147
+ display: 'flex',
148
+ flexDirection: 'column',
149
+ gap: 8,
150
+ }}
151
+ >
152
+ {visible.map((c) => {
153
+ const isRenaming = renaming?.id === c.id;
154
+ const displayName = c.title ?? 'Chart';
155
+ return (
156
+ <li key={c.id} data-testid={`charts-panel-row-${c.id}`} style={rowStyle}>
157
+ <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
158
+ <span
159
+ className="material-symbols-outlined"
160
+ aria-hidden="true"
161
+ style={{ fontSize: 18, color: MUTED, flex: '0 0 auto' }}
162
+ >
163
+ {FAMILY_ICONS[CHART_FAMILY_OF[c.type]]}
164
+ </span>
165
+ {isRenaming ? (
166
+ <input
167
+ autoFocus
168
+ value={renaming.draft}
169
+ onChange={(e) => setRenaming({ id: c.id, draft: e.target.value })}
170
+ onBlur={() => onRenameCommit(c.id, displayName)}
171
+ onKeyDown={(e) => {
172
+ if (e.key === 'Enter') onRenameCommit(c.id, displayName);
173
+ if (e.key === 'Escape') setRenaming(null);
174
+ }}
175
+ style={{ flex: 1, font: 'inherit', padding: '2px 4px' }}
176
+ />
177
+ ) : (
178
+ <button
179
+ type="button"
180
+ onClick={() => setRenaming({ id: c.id, draft: displayName })}
181
+ title="Click to rename"
182
+ style={nameBtnStyle}
183
+ >
184
+ {displayName}
185
+ </button>
186
+ )}
187
+ <IconButton
188
+ name="delete"
189
+ label={`Delete ${displayName}`}
190
+ onClick={() => remove(c.id)}
191
+ size={16}
192
+ />
193
+ </div>
194
+ <div style={{ display: 'flex', gap: 8, fontSize: 12, color: MUTED }}>
195
+ <span>{CHART_TYPE_LABEL[c.type]}</span>
196
+ <span title="Source range">{rangeToA1(c.source)}</span>
197
+ </div>
198
+ </li>
199
+ );
200
+ })}
201
+ <li style={{ listStyle: 'none', marginTop: 4 }}>
202
+ <button
203
+ type="button"
204
+ data-testid="charts-panel-add"
205
+ disabled={!univer}
206
+ onClick={openInsert}
207
+ style={ctaStyle}
208
+ >
209
+ <Icon name="add" size={16} /> Insert chart
210
+ </button>
211
+ </li>
212
+ </ul>
213
+ )}
214
+ </div>
215
+
216
+ {showInsert && univer && (
217
+ <InsertChartDialog
218
+ api={univer}
219
+ defaultSourceA1={insertDefault}
220
+ onCancel={() => setShowInsert(false)}
221
+ onConfirm={({ source, type }) => {
222
+ const model = buildChartModelForRange(univer, source, type);
223
+ if (model) {
224
+ insert({ ...model, title: nextChartName(charts) });
225
+ }
226
+ setShowInsert(false);
227
+ }}
228
+ />
229
+ )}
230
+ </div>
231
+ );
232
+ }