@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,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
|
+
}
|