@casualoffice/sheets 0.17.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.
Files changed (52) hide show
  1. package/dist/chrome.cjs +3339 -244
  2. package/dist/chrome.cjs.map +1 -1
  3. package/dist/chrome.js +3298 -203
  4. package/dist/chrome.js.map +1 -1
  5. package/dist/embed/embed-runtime.js +203 -157
  6. package/dist/index.cjs +5971 -1572
  7. package/dist/index.cjs.map +1 -1
  8. package/dist/index.js +5961 -1548
  9. package/dist/index.js.map +1 -1
  10. package/dist/sheets.cjs +4937 -538
  11. package/dist/sheets.cjs.map +1 -1
  12. package/dist/sheets.js +4945 -532
  13. package/dist/sheets.js.map +1 -1
  14. package/package.json +8 -7
  15. package/src/charts/ChartContextMenu.tsx +264 -0
  16. package/src/charts/ChartLayer.tsx +333 -0
  17. package/src/charts/ChartOverlay.tsx +293 -0
  18. package/src/charts/ChartsPanel.tsx +211 -0
  19. package/src/charts/FormatChartDialog.tsx +419 -0
  20. package/src/charts/InsertChartDialog.tsx +478 -0
  21. package/src/charts/build-option.ts +476 -0
  22. package/src/charts/charts-context.tsx +205 -0
  23. package/src/charts/echarts-init.ts +58 -0
  24. package/src/charts/hit-test.ts +130 -0
  25. package/src/charts/insert-chart.ts +106 -0
  26. package/src/charts/naming.ts +38 -0
  27. package/src/charts/render-to-png.ts +117 -0
  28. package/src/charts/resources.ts +108 -0
  29. package/src/charts/types.ts +239 -0
  30. package/src/charts/univer-dom.ts +102 -0
  31. package/src/chrome/CommentsPanel.tsx +427 -0
  32. package/src/chrome/ConditionalFormattingDialog.tsx +534 -0
  33. package/src/chrome/CustomSortDialog.tsx +357 -0
  34. package/src/chrome/DataValidationDialog.tsx +536 -0
  35. package/src/chrome/DeleteCellsDialog.tsx +183 -0
  36. package/src/chrome/GoalSeekDialog.tsx +370 -0
  37. package/src/chrome/HistoryPanel.tsx +319 -0
  38. package/src/chrome/InsertCellsDialog.tsx +185 -0
  39. package/src/chrome/InsertChartDialog.tsx +490 -0
  40. package/src/chrome/InsertFunctionDialog.tsx +493 -0
  41. package/src/chrome/InsertPivotDialog.tsx +488 -0
  42. package/src/chrome/InsertSparklineDialog.tsx +344 -0
  43. package/src/chrome/NameManagerDialog.tsx +378 -0
  44. package/src/chrome/PanelHost.tsx +55 -0
  45. package/src/chrome/PanelRail.tsx +90 -0
  46. package/src/chrome/PasteSpecialDialog.tsx +286 -0
  47. package/src/chrome/PivotFieldsPanel.tsx +1052 -0
  48. package/src/chrome/TablesPanel.tsx +301 -0
  49. package/src/chrome/dialog-context.tsx +24 -0
  50. package/src/chrome/panel-context.tsx +55 -0
  51. package/src/chrome/panel-registry.ts +48 -0
  52. package/src/sheets/CasualSheets.tsx +60 -34
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@casualoffice/sheets",
3
- "version": "0.17.0",
3
+ "version": "0.19.0",
4
4
  "description": "Casual Sheets SDK — signing pipeline, iframe postMessage protocol, and CasualSheets React wrapper around Univer.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -69,6 +69,7 @@
69
69
  "README.md"
70
70
  ],
71
71
  "peerDependencies": {
72
+ "@hocuspocus/provider": "^2.15.0",
72
73
  "@univerjs/core": "^0.25.0",
73
74
  "@univerjs/data-validation": "^0.25.0",
74
75
  "@univerjs/design": "^0.25.0",
@@ -80,6 +81,7 @@
80
81
  "@univerjs/engine-formula": "^0.25.0",
81
82
  "@univerjs/engine-render": "^0.25.0",
82
83
  "@univerjs/find-replace": "^0.25.0",
84
+ "@univerjs/rpc": "^0.25.0",
83
85
  "@univerjs/sheets": "^0.25.0",
84
86
  "@univerjs/sheets-conditional-formatting": "^0.25.0",
85
87
  "@univerjs/sheets-conditional-formatting-ui": "^0.25.0",
@@ -109,11 +111,9 @@
109
111
  "@univerjs/thread-comment": "^0.25.0",
110
112
  "@univerjs/thread-comment-ui": "^0.25.0",
111
113
  "@univerjs/ui": "^0.25.0",
112
- "@univerjs/rpc": "^0.25.0",
113
114
  "react": "^18.0.0 || ^19.0.0",
114
115
  "react-dom": "^18.0.0 || ^19.0.0",
115
- "yjs": "^13.6.0",
116
- "@hocuspocus/provider": "^2.15.0"
116
+ "yjs": "^13.6.0"
117
117
  },
118
118
  "peerDependenciesMeta": {
119
119
  "@univerjs/core": {
@@ -253,6 +253,7 @@
253
253
  }
254
254
  },
255
255
  "devDependencies": {
256
+ "@hocuspocus/provider": "^2.15.2",
256
257
  "@types/node": "^22.0.0",
257
258
  "@types/react": "^19.0.0",
258
259
  "@types/react-dom": "^19.0.0",
@@ -267,6 +268,7 @@
267
268
  "@univerjs/engine-formula": "0.25.0",
268
269
  "@univerjs/engine-render": "0.25.0",
269
270
  "@univerjs/find-replace": "0.25.0",
271
+ "@univerjs/rpc": "0.25.0",
270
272
  "@univerjs/sheets": "0.25.0",
271
273
  "@univerjs/sheets-conditional-formatting": "0.25.0",
272
274
  "@univerjs/sheets-conditional-formatting-ui": "0.25.0",
@@ -296,14 +298,12 @@
296
298
  "@univerjs/thread-comment": "0.25.0",
297
299
  "@univerjs/thread-comment-ui": "0.25.0",
298
300
  "@univerjs/ui": "0.25.0",
299
- "@univerjs/rpc": "0.25.0",
300
301
  "react": "^19.0.0",
301
302
  "react-dom": "^19.0.0",
302
303
  "tsup": "^8.0.0",
303
304
  "tsx": "^4.0.0",
304
305
  "typescript": "^5.4.0",
305
- "yjs": "^13.6.21",
306
- "@hocuspocus/provider": "^2.15.2"
306
+ "yjs": "^13.6.21"
307
307
  },
308
308
  "publishConfig": {
309
309
  "access": "public"
@@ -318,6 +318,7 @@
318
318
  "casual-sheets"
319
319
  ],
320
320
  "dependencies": {
321
+ "echarts": "6.0.0",
321
322
  "exceljs": "4.4.0"
322
323
  },
323
324
  "scripts": {
@@ -0,0 +1,264 @@
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 { useEffect, useRef, useState } from 'react';
18
+ import type { FUniver } from '@univerjs/core/facade';
19
+ import { Icon } from '../chrome/Icon';
20
+ import { useCharts } from './charts-context';
21
+ import { InsertChartDialog } from './InsertChartDialog';
22
+ import { FormatChartDialog } from './FormatChartDialog';
23
+
24
+ /**
25
+ * Right-click context menu for a selected chart. Mirrors Excel's
26
+ * chart context menu, scoped to the actions we support today:
27
+ *
28
+ * - Change Chart Type → reopens the Insert dialog seeded with the
29
+ * chart's current source range and type.
30
+ * - Rename → in-place edit (focus jumps to the panel rename if it's
31
+ * open, otherwise inline prompt — for v0.1.1 we just toggle a
32
+ * small inline input here).
33
+ * - Delete → drops the chart from the store.
34
+ *
35
+ * Closes on outside click or Escape.
36
+ */
37
+ type Props = {
38
+ chartId: string;
39
+ x: number;
40
+ y: number;
41
+ onClose: () => void;
42
+ };
43
+
44
+ export function ChartContextMenu({ chartId, x, y, onClose }: Props) {
45
+ const { api: sdkApi, charts, remove, update } = useCharts();
46
+ // FUniver facade — reached through the SDK handle.
47
+ const api = sdkApi.univer;
48
+ const chart = charts.find((c) => c.id === chartId);
49
+ const menuRef = useRef<HTMLDivElement>(null);
50
+ const [renaming, setRenaming] = useState<string | null>(null);
51
+ const [showChangeType, setShowChangeType] = useState(false);
52
+ const [showFormat, setShowFormat] = useState(false);
53
+ const dialogOpen = showChangeType || showFormat;
54
+
55
+ useEffect(() => {
56
+ const onKey = (e: KeyboardEvent) => {
57
+ if (e.key === 'Escape') {
58
+ if (dialogOpen) return; // dialog handles its own Escape
59
+ onClose();
60
+ }
61
+ };
62
+ const onDown = (e: MouseEvent) => {
63
+ const t = e.target as HTMLElement | null;
64
+ if (!menuRef.current) return;
65
+ if (t && menuRef.current.contains(t)) return;
66
+ if (t?.closest('.dialog-backdrop')) return;
67
+ if (t?.closest('[data-testid="cs-dialog-backdrop"]')) return;
68
+ onClose();
69
+ };
70
+ document.addEventListener('keydown', onKey);
71
+ document.addEventListener('mousedown', onDown, true);
72
+ return () => {
73
+ document.removeEventListener('keydown', onKey);
74
+ document.removeEventListener('mousedown', onDown, true);
75
+ };
76
+ }, [onClose, dialogOpen]);
77
+
78
+ // The Format dialog side-channels the title change because title
79
+ // text lives on `ChartModel`, not `ChartFormat`. Catch it and apply.
80
+ useEffect(() => {
81
+ const onTitle = (e: Event) => {
82
+ const ce = e as CustomEvent<{ id: string; title?: string }>;
83
+ if (ce.detail.id !== chartId) return;
84
+ update(chartId, { title: ce.detail.title });
85
+ };
86
+ document.addEventListener('casual-chart-title-changed', onTitle);
87
+ return () => document.removeEventListener('casual-chart-title-changed', onTitle);
88
+ }, [chartId, update]);
89
+
90
+ if (!chart) return null;
91
+
92
+ // Anchor the menu inside the viewport — flip when too close to the
93
+ // right/bottom edges. Excel does the same.
94
+ const W = 200;
95
+ const H = 200; // conservative; menu auto-sizes anyway
96
+ const left = Math.min(x, window.innerWidth - W - 8);
97
+ const top = Math.min(y, window.innerHeight - H - 8);
98
+
99
+ if (renaming !== null) {
100
+ return (
101
+ <div
102
+ ref={menuRef}
103
+ className="chart-context-menu chart-context-menu--renaming"
104
+ data-testid="chart-context-menu"
105
+ style={{ position: 'fixed', left, top, pointerEvents: 'auto' }}
106
+ >
107
+ <input
108
+ autoFocus
109
+ className="chart-context-menu__rename-input"
110
+ data-testid="chart-context-rename-input"
111
+ value={renaming}
112
+ onChange={(e) => setRenaming(e.target.value)}
113
+ onKeyDown={(e) => {
114
+ if (e.key === 'Enter') {
115
+ const v = renaming.trim();
116
+ if (v) update(chartId, { title: v });
117
+ onClose();
118
+ }
119
+ if (e.key === 'Escape') onClose();
120
+ }}
121
+ onBlur={() => {
122
+ const v = renaming.trim();
123
+ if (v && v !== chart.title) update(chartId, { title: v });
124
+ onClose();
125
+ }}
126
+ />
127
+ </div>
128
+ );
129
+ }
130
+
131
+ return (
132
+ <>
133
+ <div
134
+ ref={menuRef}
135
+ className="chart-context-menu"
136
+ data-testid="chart-context-menu"
137
+ style={{ position: 'fixed', left, top, pointerEvents: 'auto' }}
138
+ role="menu"
139
+ >
140
+ <button
141
+ type="button"
142
+ className="chart-context-menu__item"
143
+ data-testid="chart-context-change-type"
144
+ onClick={() => setShowChangeType(true)}
145
+ >
146
+ <Icon name="bar_chart" />
147
+ <span>Change chart type…</span>
148
+ </button>
149
+ <button
150
+ type="button"
151
+ className="chart-context-menu__item"
152
+ data-testid="chart-context-format"
153
+ onClick={() => setShowFormat(true)}
154
+ >
155
+ <Icon name="tune" />
156
+ <span>Format chart…</span>
157
+ </button>
158
+ <button
159
+ type="button"
160
+ className="chart-context-menu__item"
161
+ data-testid="chart-context-rename"
162
+ onClick={() => setRenaming(chart.title ?? 'Chart')}
163
+ >
164
+ <Icon name="edit" />
165
+ <span>Rename</span>
166
+ </button>
167
+ <div className="chart-context-menu__sep" />
168
+ <button
169
+ type="button"
170
+ className="chart-context-menu__item chart-context-menu__item--danger"
171
+ data-testid="chart-context-delete"
172
+ onClick={() => {
173
+ remove(chartId);
174
+ onClose();
175
+ }}
176
+ >
177
+ <Icon name="delete" />
178
+ <span>Delete chart</span>
179
+ </button>
180
+ </div>
181
+
182
+ {showChangeType && api && (
183
+ <InsertChartDialog
184
+ api={api}
185
+ defaultSourceA1={rangeToA1(chart.source)}
186
+ initialType={chart.type}
187
+ title="Change chart type"
188
+ confirmLabel="Apply"
189
+ onCancel={() => {
190
+ setShowChangeType(false);
191
+ onClose();
192
+ }}
193
+ onConfirm={({ source, type }) => {
194
+ update(chartId, { source, type });
195
+ setShowChangeType(false);
196
+ onClose();
197
+ }}
198
+ />
199
+ )}
200
+
201
+ {showFormat && (
202
+ <FormatChartDialog
203
+ model={chart}
204
+ seriesNames={readSeriesNames(api, chart)}
205
+ onCancel={() => {
206
+ setShowFormat(false);
207
+ onClose();
208
+ }}
209
+ onConfirm={(next) => {
210
+ update(chartId, { format: next });
211
+ setShowFormat(false);
212
+ onClose();
213
+ }}
214
+ />
215
+ )}
216
+ </>
217
+ );
218
+ }
219
+
220
+ function rangeToA1(r: {
221
+ startRow: number;
222
+ endRow: number;
223
+ startColumn: number;
224
+ endColumn: number;
225
+ }): string {
226
+ const a = colA1(r.startColumn) + (r.startRow + 1);
227
+ if (r.startRow === r.endRow && r.startColumn === r.endColumn) return a;
228
+ return `${a}:${colA1(r.endColumn)}${r.endRow + 1}`;
229
+ }
230
+
231
+ function colA1(c: number): string {
232
+ let n = c + 1;
233
+ let s = '';
234
+ while (n > 0) {
235
+ const rem = (n - 1) % 26;
236
+ s = String.fromCharCode(65 + rem) + s;
237
+ n = Math.floor((n - 1) / 26);
238
+ }
239
+ return s;
240
+ }
241
+
242
+ function readSeriesNames(
243
+ api: FUniver | null,
244
+ chart: { sheetId: string; source: { startRow: number; startColumn: number; endColumn: number } },
245
+ ): string[] {
246
+ if (!api) return [];
247
+ try {
248
+ const wb = api.getActiveWorkbook();
249
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
250
+ const sheets = wb?.getSheets() as any[];
251
+ const ws = sheets?.find((s) => s.getSheetId?.() === chart.sheetId);
252
+ if (!ws) return [];
253
+ const { startRow, startColumn, endColumn } = chart.source;
254
+ const names: string[] = [];
255
+ for (let c = 1; c <= endColumn - startColumn; c += 1) {
256
+ const v = ws.getRange(startRow, startColumn + c).getValue();
257
+ names.push(v == null ? `Series ${c}` : String(v));
258
+ }
259
+ return names;
260
+ } catch (err) {
261
+ console.debug('[chart-format] failed to read series names', err);
262
+ return [];
263
+ }
264
+ }
@@ -0,0 +1,333 @@
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 { useEffect, useRef, useState } from 'react';
18
+ import { createPortal } from 'react-dom';
19
+ import { getHeaderGutter, getUniverHost, getUniverMainCanvas } from './univer-dom';
20
+ import { useCharts } from './charts-context';
21
+ import { ChartOverlay } from './ChartOverlay';
22
+ import { ChartContextMenu } from './ChartContextMenu';
23
+
24
+ /**
25
+ * Renders every chart in the store. Same anchoring strategy as
26
+ * PresenceLayer:
27
+ *
28
+ * - Find the univer-host + main grid canvas.
29
+ * - Translate the chart's cell-coordinate pos into a host-local
30
+ * CSS box: `getCellRect(corner)` gives canvas-local pre-scroll
31
+ * coords; subtract `scrollX/scrollY` (tracked via Univer's
32
+ * Scroll event), add the canvas-vs-host offset.
33
+ * - Only render charts whose sheet matches the active sheet
34
+ * (Excel hides charts on inactive sheets).
35
+ * - Rerun the math every animation frame so the chart sticks
36
+ * to its cell anchor through scroll + resize + zoom.
37
+ *
38
+ * Mounts into the univer-host via a portal so the overlay paints
39
+ * on top of the canvas at the same z-stack as PresenceLayer.
40
+ */
41
+ type RenderedChart = {
42
+ id: string;
43
+ rect: { left: number; top: number; width: number; height: number };
44
+ };
45
+
46
+ type CtxMenuState = { id: string; x: number; y: number } | null;
47
+
48
+ export function ChartLayer() {
49
+ const { api: sdkApi, charts, selectedId, select, remove } = useCharts();
50
+ // FUniver facade — reached through the SDK handle (the app used a
51
+ // `useUniverAPI()` hook that doesn't exist in the packaged SDK).
52
+ const api = sdkApi.univer;
53
+ const [rendered, setRendered] = useState<RenderedChart[]>([]);
54
+ const [ctxMenu, setCtxMenu] = useState<CtxMenuState>(null);
55
+ const [canvasOffset, setCanvasOffset] = useState({ x: 0, y: 0 });
56
+ // rAF callback closes over `rendered` from when the effect ran. The effect
57
+ // re-runs only on [api, charts] changes, so a sheet-switch (which leaves
58
+ // both inputs untouched) would compare new computed rects against the
59
+ // stale closure copy — `rectsEqual` could return true while React state
60
+ // still holds the previous overlays. Mirror state into a ref so the
61
+ // diff always sees the live value.
62
+ const renderedRef = useRef<RenderedChart[]>(rendered);
63
+ renderedRef.current = rendered;
64
+ const hostRef = useRef<HTMLElement | null>(null);
65
+ const scrollRef = useRef({ x: 0, y: 0 });
66
+
67
+ useEffect(() => {
68
+ hostRef.current = getUniverHost();
69
+ }, []);
70
+
71
+ // The chart's screen position is the cell's canvas-local rect minus
72
+ // the viewport scroll offset. `Event.Scroll` would be the natural
73
+ // hook, but it doesn't fire on programmatic scrolls (scrollToCell)
74
+ // and the registration timing is fragile against lifecycle stages.
75
+ // `getScrollState()` is the authoritative read — poll it every
76
+ // animation frame and diff. Cheap (a few number reads) and correct.
77
+
78
+ useEffect(() => {
79
+ if (!api) return;
80
+ let raf = 0;
81
+
82
+ const recompute = () => {
83
+ const host = hostRef.current ?? getUniverHost();
84
+ if (!host) {
85
+ if (renderedRef.current.length) setRendered([]);
86
+ return;
87
+ }
88
+ const canvas = getUniverMainCanvas(host);
89
+ if (!canvas) {
90
+ if (renderedRef.current.length) setRendered([]);
91
+ return;
92
+ }
93
+ const hostRect = host.getBoundingClientRect();
94
+ const canvasRect = canvas.getBoundingClientRect();
95
+ const dx = canvasRect.left - hostRect.left;
96
+ const dy = canvasRect.top - hostRect.top;
97
+ if (dx !== canvasOffset.x || dy !== canvasOffset.y) {
98
+ setCanvasOffset({ x: dx, y: dy });
99
+ }
100
+
101
+ const wb = api.getActiveWorkbook();
102
+ if (!wb) {
103
+ if (renderedRef.current.length) setRendered([]);
104
+ return;
105
+ }
106
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
107
+ const activeSheet = wb.getActiveSheet() as any;
108
+ const activeSheetId = activeSheet?.getSheetId?.();
109
+
110
+ // Read viewport scroll directly from the worksheet — each frame
111
+ // is cheap (a handful of property reads) and dodges Univer's
112
+ // flaky `Event.Scroll` dispatch (doesn't fire on `scrollToCell`,
113
+ // and the listener registration races the render lifecycle).
114
+ // Cell rects come back canvas-local + pre-scroll; we convert by
115
+ // asking for the rect of the cell currently at viewport top-left
116
+ // — that rect's `top/left` IS the scroll offset to subtract.
117
+ // CRITICAL: `getScrollState()` itself can throw a redi
118
+ // QuantityCheckError during the brief window between Univer mount
119
+ // and full render-unit DI graph setup. The whole block —
120
+ // including the getScrollState() call — must be inside try/catch.
121
+ // Otherwise the rAF tick throws every frame until init completes.
122
+ let sx = 0;
123
+ let sy = 0;
124
+ try {
125
+ const scrollState = activeSheet?.getScrollState?.() as
126
+ | {
127
+ sheetViewStartRow?: number;
128
+ sheetViewStartColumn?: number;
129
+ offsetX?: number;
130
+ offsetY?: number;
131
+ }
132
+ | undefined;
133
+ if (scrollState) {
134
+ const r = scrollState.sheetViewStartRow ?? 0;
135
+ const c = scrollState.sheetViewStartColumn ?? 0;
136
+ const topLeft = activeSheet.getRange(r, c).getCellRect();
137
+ if (topLeft) {
138
+ sx = topLeft.left + (scrollState.offsetX ?? 0);
139
+ sy = topLeft.top + (scrollState.offsetY ?? 0);
140
+ }
141
+ }
142
+ } catch {
143
+ /* skeleton / scroll service not ready — leave scroll at 0 this frame */
144
+ }
145
+ // Stash the latest scroll for downstream consumers (ChartOverlay).
146
+ // We no longer need a tick counter to gate recomputes — they fire
147
+ // every frame now.
148
+ scrollRef.current = { x: sx, y: sy };
149
+
150
+ // Header gutter — see univer-dom.getHeaderGutter. Without this,
151
+ // charts sit ~40 px above and left of their cell anchor.
152
+ const gutter = getHeaderGutter(api);
153
+
154
+ // Zoom — getCellRect returns logical content coords; Univer
155
+ // applies zoom as a scene transform when drawing the canvas.
156
+ // Multiply (content - scroll) by zoom so the overlay tracks
157
+ // the canvas at any zoom level. See PresenceLayer for the
158
+ // same pattern + rationale.
159
+ let zoom = 1;
160
+ try {
161
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
162
+ const ws = activeSheet as any;
163
+ const z =
164
+ (ws?._worksheet?.getZoomRatio?.() as number | undefined) ??
165
+ (ws?.getZoomRatio?.() as number | undefined);
166
+ if (typeof z === 'number' && z > 0) zoom = z;
167
+ } catch {
168
+ /* zoom unreadable — leave at 1 */
169
+ }
170
+
171
+ const out: RenderedChart[] = [];
172
+ for (const c of charts) {
173
+ if (c.sheetId !== activeSheetId) continue;
174
+ try {
175
+ const tl = activeSheet.getRange(c.pos.startRow, c.pos.startColumn).getCellRect();
176
+ const br = activeSheet.getRange(c.pos.endRow, c.pos.endColumn).getCellRect();
177
+ if (!tl || !br) continue;
178
+ const left = (Math.min(tl.left, br.left) - sx) * zoom + dx + gutter.rowHeaderWidth;
179
+ const top = (Math.min(tl.top, br.top) - sy) * zoom + dy + gutter.columnHeaderHeight;
180
+ const right = (Math.max(tl.right, br.right) - sx) * zoom + dx + gutter.rowHeaderWidth;
181
+ const bottom =
182
+ (Math.max(tl.bottom, br.bottom) - sy) * zoom + dy + gutter.columnHeaderHeight;
183
+ // Clip — charts mostly off-canvas don't render (saves
184
+ // ECharts a redraw); partially off is fine because the
185
+ // overlay overflow:hidden on the layer below clips it.
186
+ if (right < dx || bottom < dy) continue;
187
+ if (left > dx + canvasRect.width || top > dy + canvasRect.height) continue;
188
+ out.push({
189
+ id: c.id,
190
+ rect: { left, top, width: right - left, height: bottom - top },
191
+ });
192
+ } catch {
193
+ /* getCellRect can throw mid-resize — drop this frame */
194
+ }
195
+ }
196
+ if (rectsEqual(out, renderedRef.current)) return;
197
+ setRendered(out);
198
+ };
199
+
200
+ const tick = () => {
201
+ // Recompute every animation frame. The cost is dominated by a
202
+ // handful of `getCellRect` calls per chart and one `getHeaderGutter`
203
+ // lookup — easily under a millisecond for typical workbooks.
204
+ // Throttling to every 4 frames was the source of charts visually
205
+ // pinning to the viewport for ~50 ms at the start of any scroll
206
+ // (same root cause as the remote-cursor scroll-pin bug —
207
+ // see docs/COLLAB-FIXES.md #14).
208
+ recompute();
209
+ raf = requestAnimationFrame(tick);
210
+ };
211
+ raf = requestAnimationFrame(tick);
212
+ return () => cancelAnimationFrame(raf);
213
+ // charts + api are the inputs; rendered is the output.
214
+ // eslint-disable-next-line react-hooks/exhaustive-deps
215
+ }, [api, charts]);
216
+
217
+ // Delete key removes the selected chart. Excel uses Delete/Backspace
218
+ // both — match that. We ignore the press if the focus is in a text
219
+ // input (formula bar, cell editor, dialog inputs) so the user can
220
+ // still delete characters there. Also Esc clears selection.
221
+ useEffect(() => {
222
+ const onKey = (e: KeyboardEvent) => {
223
+ if (!selectedId) return;
224
+ const t = e.target as HTMLElement | null;
225
+ const tag = t?.tagName;
226
+ const inText = tag === 'INPUT' || tag === 'TEXTAREA' || (t?.isContentEditable ?? false);
227
+ if (e.key === 'Escape') {
228
+ select(null);
229
+ return;
230
+ }
231
+ if (!inText && (e.key === 'Delete' || e.key === 'Backspace')) {
232
+ e.preventDefault();
233
+ remove(selectedId);
234
+ }
235
+ };
236
+ document.addEventListener('keydown', onKey);
237
+ return () => document.removeEventListener('keydown', onKey);
238
+ }, [selectedId, remove, select]);
239
+
240
+ // Listen for the right-click event ChartOverlay dispatches. We can't
241
+ // mount the context menu inside ChartOverlay because the menu needs
242
+ // to escape the overlay's clipping + sit at viewport coords.
243
+ useEffect(() => {
244
+ const onCtx = (e: Event) => {
245
+ const ce = e as CustomEvent<{ id: string; x: number; y: number }>;
246
+ setCtxMenu(ce.detail);
247
+ };
248
+ document.addEventListener('casual-chart-contextmenu', onCtx);
249
+ return () => document.removeEventListener('casual-chart-contextmenu', onCtx);
250
+ }, []);
251
+
252
+ // Click anywhere outside any chart deselects. Capture-phase so the
253
+ // grid canvas doesn't consume the event first. Skip if the click
254
+ // originated inside a chart overlay or the context menu.
255
+ useEffect(() => {
256
+ const onClick = (e: MouseEvent) => {
257
+ const t = e.target as HTMLElement | null;
258
+ if (!t) return;
259
+ if (t.closest('.chart-overlay')) return;
260
+ if (t.closest('.chart-context-menu')) return;
261
+ if (selectedId) select(null);
262
+ };
263
+ document.addEventListener('mousedown', onClick, true);
264
+ return () => document.removeEventListener('mousedown', onClick, true);
265
+ }, [selectedId, select]);
266
+
267
+ const host = hostRef.current ?? getUniverHost();
268
+ if (rendered.length === 0 && !ctxMenu) return null;
269
+ if (!host) return null;
270
+
271
+ return createPortal(
272
+ <div
273
+ className="chart-layer"
274
+ data-testid="chart-layer"
275
+ // z-index matches presence-layer so chart overlays sit above
276
+ // Univer's main grid canvas (which has its own stacking context).
277
+ // pointerEvents: none keeps clicks on empty space passing through
278
+ // to the canvas; .chart-overlay re-enables pointer events.
279
+ style={{
280
+ position: 'absolute',
281
+ inset: 0,
282
+ overflow: 'hidden',
283
+ pointerEvents: 'none',
284
+ zIndex: 60,
285
+ }}
286
+ >
287
+ {rendered.map((r) => {
288
+ const model = charts.find((c) => c.id === r.id);
289
+ if (!model) return null;
290
+ // ChartOverlay positions itself absolutely (left/top/w/h from
291
+ // `r.rect`). Render it directly — the previous wrapper used
292
+ // `inset: 0` which covered the ENTIRE host with pointer-events
293
+ // and intercepted clicks meant for empty grid cells.
294
+ return (
295
+ <ChartOverlay
296
+ key={r.id}
297
+ model={model}
298
+ rect={r.rect}
299
+ canvasOffset={canvasOffset}
300
+ scroll={scrollRef.current}
301
+ />
302
+ );
303
+ })}
304
+ {ctxMenu && (
305
+ <ChartContextMenu
306
+ chartId={ctxMenu.id}
307
+ x={ctxMenu.x}
308
+ y={ctxMenu.y}
309
+ onClose={() => setCtxMenu(null)}
310
+ />
311
+ )}
312
+ </div>,
313
+ host,
314
+ );
315
+ }
316
+
317
+ function rectsEqual(a: RenderedChart[], b: RenderedChart[]): boolean {
318
+ if (a.length !== b.length) return false;
319
+ for (let i = 0; i < a.length; i += 1) {
320
+ const x = a[i];
321
+ const y = b[i];
322
+ if (x.id !== y.id) return false;
323
+ if (
324
+ x.rect.left !== y.rect.left ||
325
+ x.rect.top !== y.rect.top ||
326
+ x.rect.width !== y.rect.width ||
327
+ x.rect.height !== y.rect.height
328
+ ) {
329
+ return false;
330
+ }
331
+ }
332
+ return true;
333
+ }