@casualoffice/sheets 0.18.0 → 0.19.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.
@@ -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,211 @@
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 { useCharts } from './charts-context';
21
+ import { getActiveSelectionRange, rangeToA1, buildChartModelForRange } from './insert-chart';
22
+ import { nextChartName } from './naming';
23
+ import { InsertChartDialog } from './InsertChartDialog';
24
+ import { CHART_FAMILY_OF, CHART_TYPE_LABEL, type ChartFamily, type ChartModel } from './types';
25
+
26
+ /**
27
+ * Right-side Charts panel. Equivalent of Excel's Selection Pane scoped
28
+ * to charts on the active sheet: list every chart, click the name to
29
+ * rename, click the source-range badge to flash that range in the grid,
30
+ * delete from the row, and "Insert chart" from the empty-state CTA.
31
+ *
32
+ * Only charts on the active sheet are shown — same scoping Excel uses
33
+ * for its Selection Pane (it switches with the active sheet tab).
34
+ *
35
+ * SDK port: this is a chrome side-panel (`{ api, onClose }`). The app's
36
+ * `useUI().toggleChartsPanel` becomes the panel host's `onClose`; the
37
+ * FUniver facade is reached through `api.univer`. Chart data still comes
38
+ * from `useCharts()` (mount `<ChartsProvider api={...}>` above the chrome).
39
+ */
40
+ const FAMILY_ICONS: Record<ChartFamily, string> = {
41
+ column: 'bar_chart',
42
+ bar: 'align_horizontal_left',
43
+ line: 'show_chart',
44
+ area: 'area_chart',
45
+ pie: 'pie_chart',
46
+ scatter: 'scatter_plot',
47
+ };
48
+
49
+ export function ChartsPanel({ api, onClose }: PanelComponentProps) {
50
+ // FUniver facade — reached through the SDK handle.
51
+ const univer = api.univer;
52
+ const { charts, insert, remove, update } = useCharts();
53
+ const [renaming, setRenaming] = useState<{ id: string; draft: string } | null>(null);
54
+ const [showInsert, setShowInsert] = useState(false);
55
+ const [insertDefault, setInsertDefault] = useState('A1');
56
+
57
+ const activeSheetId = useMemo(() => {
58
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
59
+ const ws: any = univer?.getActiveWorkbook?.()?.getActiveSheet();
60
+ return ws?.getSheetId?.() ?? null;
61
+ }, [univer, charts]);
62
+
63
+ const visible = useMemo<ChartModel[]>(
64
+ () => (activeSheetId ? charts.filter((c) => c.sheetId === activeSheetId) : charts),
65
+ [charts, activeSheetId],
66
+ );
67
+ const empty = visible.length === 0;
68
+
69
+ const onRenameCommit = (id: string, prev: string) => {
70
+ if (!renaming || renaming.id !== id) return;
71
+ const draft = renaming.draft.trim();
72
+ if (!draft || draft === prev) {
73
+ setRenaming(null);
74
+ return;
75
+ }
76
+ update(id, { title: draft });
77
+ setRenaming(null);
78
+ };
79
+
80
+ const openInsert = () => {
81
+ if (!univer) return;
82
+ const sel = getActiveSelectionRange(univer);
83
+ setInsertDefault(sel ? rangeToA1(sel) : 'A1');
84
+ setShowInsert(true);
85
+ };
86
+
87
+ return (
88
+ <aside className="side-panel charts-panel" data-testid="charts-panel">
89
+ <header className="side-panel__header">
90
+ <Icon name="bar_chart" size={16} />
91
+ <h2 className="side-panel__title">Charts</h2>
92
+ {!empty && <span className="side-panel__count">{visible.length}</span>}
93
+ <button
94
+ type="button"
95
+ className="side-panel__close"
96
+ aria-label="Close charts panel"
97
+ onClick={onClose}
98
+ >
99
+ <Icon name="close" size={16} />
100
+ </button>
101
+ </header>
102
+ <div className="charts-panel__body">
103
+ {empty ? (
104
+ <div className="charts-panel__empty" data-testid="charts-panel-empty">
105
+ <Icon name="bar_chart" size={32} style={{ opacity: 0.4 }} />
106
+ <div className="charts-panel__empty-title">No charts on this sheet</div>
107
+ <div className="charts-panel__empty-body">
108
+ Select the data range you want to plot, then click below — or use{' '}
109
+ <strong>Insert → Chart</strong> from the menu.
110
+ </div>
111
+ <button
112
+ type="button"
113
+ className="btn-primary charts-panel__empty-cta"
114
+ data-testid="charts-panel-empty-cta"
115
+ disabled={!univer}
116
+ onClick={openInsert}
117
+ >
118
+ Insert chart
119
+ </button>
120
+ </div>
121
+ ) : (
122
+ <ul className="charts-panel__list">
123
+ {visible.map((c) => {
124
+ const isRenaming = renaming?.id === c.id;
125
+ const displayName = c.title ?? 'Chart';
126
+ return (
127
+ <li
128
+ className="charts-panel__row"
129
+ key={c.id}
130
+ data-testid={`charts-panel-row-${c.id}`}
131
+ >
132
+ <div className="charts-panel__name">
133
+ <span
134
+ className="material-symbols-outlined charts-panel__type-icon"
135
+ aria-hidden="true"
136
+ >
137
+ {FAMILY_ICONS[CHART_FAMILY_OF[c.type]]}
138
+ </span>
139
+ {isRenaming ? (
140
+ <input
141
+ autoFocus
142
+ className="charts-panel__name-input"
143
+ value={renaming.draft}
144
+ onChange={(e) => setRenaming({ id: c.id, draft: e.target.value })}
145
+ onBlur={() => onRenameCommit(c.id, displayName)}
146
+ onKeyDown={(e) => {
147
+ if (e.key === 'Enter') onRenameCommit(c.id, displayName);
148
+ if (e.key === 'Escape') setRenaming(null);
149
+ }}
150
+ />
151
+ ) : (
152
+ <button
153
+ type="button"
154
+ className="charts-panel__name-btn"
155
+ onClick={() => setRenaming({ id: c.id, draft: displayName })}
156
+ title="Click to rename"
157
+ >
158
+ {displayName}
159
+ </button>
160
+ )}
161
+ </div>
162
+ <div className="charts-panel__meta">
163
+ <span className="charts-panel__type-label">{CHART_TYPE_LABEL[c.type]}</span>
164
+ <span className="charts-panel__range" title="Source range">
165
+ {rangeToA1(c.source)}
166
+ </span>
167
+ </div>
168
+ <button
169
+ type="button"
170
+ className="charts-panel__delete"
171
+ aria-label={`Delete ${displayName}`}
172
+ title="Delete chart"
173
+ onClick={() => remove(c.id)}
174
+ >
175
+ <Icon name="delete" />
176
+ </button>
177
+ </li>
178
+ );
179
+ })}
180
+ <li className="charts-panel__add-row">
181
+ <button
182
+ type="button"
183
+ className="btn-secondary charts-panel__add"
184
+ data-testid="charts-panel-add"
185
+ disabled={!univer}
186
+ onClick={openInsert}
187
+ >
188
+ <Icon name="add" /> Insert chart
189
+ </button>
190
+ </li>
191
+ </ul>
192
+ )}
193
+ </div>
194
+
195
+ {showInsert && univer && (
196
+ <InsertChartDialog
197
+ api={univer}
198
+ defaultSourceA1={insertDefault}
199
+ onCancel={() => setShowInsert(false)}
200
+ onConfirm={({ source, type }) => {
201
+ const model = buildChartModelForRange(univer, source, type);
202
+ if (model) {
203
+ insert({ ...model, title: nextChartName(charts) });
204
+ }
205
+ setShowInsert(false);
206
+ }}
207
+ />
208
+ )}
209
+ </aside>
210
+ );
211
+ }