@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.
- package/dist/embed/embed-runtime.js +205 -161
- package/dist/index.cjs +5971 -1572
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +5961 -1548
- package/dist/index.js.map +1 -1
- package/dist/sheets.cjs +4937 -538
- package/dist/sheets.cjs.map +1 -1
- package/dist/sheets.js +4945 -532
- 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 +211 -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 +427 -0
- package/src/chrome/HistoryPanel.tsx +319 -0
- package/src/chrome/PanelHost.tsx +55 -0
- package/src/chrome/PanelRail.tsx +90 -0
- package/src/chrome/PivotFieldsPanel.tsx +1052 -0
- package/src/chrome/TablesPanel.tsx +301 -0
- package/src/chrome/panel-context.tsx +55 -0
- package/src/chrome/panel-registry.ts +48 -0
- 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.
|
|
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
|
+
}
|