@spacecorexdev/core 0.0.4 → 0.0.5
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/package.json +2 -36
- package/src/api/_base.ts +243 -0
- package/src/api/index.ts +17 -0
- package/src/api/project.ts +1345 -0
- package/src/api/user.ts +1174 -0
- package/src/contract.ts +50 -0
- package/src/hooks/index.ts +2 -0
- package/{dist/hooks/index.js → src/hooks/useLongPress.ts} +53 -49
- package/src/hooks/useOnlineStatus.ts +22 -0
- package/src/index.ts +14 -0
- package/src/types/id.ts +2 -0
- package/src/types/index.ts +5 -0
- package/src/types/project/base.ts +63 -0
- package/src/types/project/contract.ts +79 -0
- package/src/types/project/contractStatus.tsx +15 -0
- package/src/types/project/index.ts +5 -0
- package/src/types/project/member.ts +46 -0
- package/src/types/project/progress.ts +43 -0
- package/src/types/projectRole.ts +17 -0
- package/src/types/theme.ts +1 -0
- package/src/types/user/index.ts +1 -0
- package/src/types/user/role.ts +7 -0
- package/src/ui/CommentTimeline/index.tsx +330 -0
- package/src/ui/FileManager/FileContextMenu.tsx +628 -0
- package/src/ui/FileManager/FileGridView.tsx +529 -0
- package/src/ui/FileManager/FileTableView.tsx +405 -0
- package/src/ui/FileManager/FileToolbar.tsx +119 -0
- package/src/ui/FileManager/FileVersionHistoryModal.tsx +175 -0
- package/src/ui/FileManager/MoveCopyToProjectModal.tsx +119 -0
- package/src/ui/FileManager/ShareToWechatModal.tsx +419 -0
- package/src/ui/FileManager/hooks/useFileManagerActions.ts +2532 -0
- package/src/ui/FileManager/hooks/useFileManagerState.ts +419 -0
- package/src/ui/FileManager/index.tsx +580 -0
- package/src/ui/FileManager/projectFileManagerApi.ts +57 -0
- package/src/ui/FileManager/types.ts +184 -0
- package/src/ui/FileManager/utils/fileDeltaWorker.ts +193 -0
- package/src/ui/FileManager/utils/fileTree.ts +178 -0
- package/src/ui/FileManager/utils/fileUpload.ts +714 -0
- package/src/ui/FileManager/utils/fileUtils.ts +65 -0
- package/src/ui/InvoiceRecordsList/index.tsx +813 -0
- package/src/ui/MyFileSelectorModal.tsx +261 -0
- package/src/ui/ProjectSelector/index.tsx +472 -0
- package/src/ui/ProjectSelector/projectCache.ts +40 -0
- package/src/ui/RoleSelector/index.tsx +473 -0
- package/src/ui/RoleSelector/roleCache.ts +40 -0
- package/src/ui/UIAlert.tsx +399 -0
- package/src/ui/UIAlertExample.tsx +129 -0
- package/src/ui/UIConfirm.tsx +335 -0
- package/src/ui/UIConfirmExample.tsx +165 -0
- package/src/ui/UIDatePicker.tsx +146 -0
- package/src/ui/UIDateRangePicker.tsx +38 -0
- package/src/ui/UIFilePreview/ExcelEditor/index.tsx +617 -0
- package/src/ui/UIFilePreview/ExcelEditor/types.ts +24 -0
- package/src/ui/UIFilePreview/ExportDropdown.css +85 -0
- package/src/ui/UIFilePreview/ExportDropdown.tsx +109 -0
- package/src/ui/UIFilePreview/IndexedDBUtils.ts +564 -0
- package/src/ui/UIFilePreview/MarkdownEditor/exportMarkdownPdf.ts +576 -0
- package/src/ui/UIFilePreview/MarkdownEditor/index.tsx +677 -0
- package/src/ui/UIFilePreview/MarkdownEditor/markdownEditor.css +1005 -0
- package/src/ui/UIFilePreview/MarkdownEditor/types.ts +15 -0
- package/src/ui/UIFilePreview/PPTXEditor/index.tsx +602 -0
- package/src/ui/UIFilePreview/RichDocEditor/exportRDocMarkdown.ts +77 -0
- package/src/ui/UIFilePreview/RichDocEditor/exportRDocPdf.ts +374 -0
- package/src/ui/UIFilePreview/RichDocEditor/index.tsx +730 -0
- package/src/ui/UIFilePreview/RichDocEditor/richdoc-editor.css +59 -0
- package/src/ui/UIFilePreview/UIFilePreview.tsx +611 -0
- package/src/ui/UIFilePreview/WordEditor/index.tsx +3933 -0
- package/src/ui/UIFilePreview/WordEditor/style.ts +1213 -0
- package/src/ui/UIFilePreview/WordEditor/word-editor.css +999 -0
- package/src/ui/UIFilePreview/filePreview.css +193 -0
- package/src/ui/UIFilePreview/filetypes/archive.tsx +66 -0
- package/src/ui/UIFilePreview/filetypes/cad.tsx +287 -0
- package/src/ui/UIFilePreview/filetypes/excel.tsx +355 -0
- package/src/ui/UIFilePreview/filetypes/image.tsx +20 -0
- package/src/ui/UIFilePreview/filetypes/imageLoader.ts +63 -0
- package/src/ui/UIFilePreview/filetypes/pdf.tsx +156 -0
- package/src/ui/UIFilePreview/filetypes/ppt.tsx +535 -0
- package/src/ui/UIFilePreview/filetypes/pptExportPdf.ts +523 -0
- package/src/ui/UIFilePreview/filetypes/video.tsx +482 -0
- package/src/ui/UIFilePreview/filetypes/word.tsx +245 -0
- package/src/ui/UIFilePreview/filetypes/wordExportMarkdown.ts +86 -0
- package/src/ui/UIFilePreview/filetypes/wordExportPdf.tsx +427 -0
- package/src/ui/UIFilePreview/index.ts +35 -0
- package/src/ui/UIFilePreview/pdfPagination.ts +143 -0
- package/src/ui/UIFilePreview/previewUtils.tsx +531 -0
- package/src/ui/UIFileUpload.tsx +382 -0
- package/src/ui/UIImageUpload.tsx +195 -0
- package/src/ui/UILoading.tsx +255 -0
- package/src/ui/UILoadingExample.tsx +139 -0
- package/src/ui/UISelect.tsx +105 -0
- package/src/ui/UIToast.tsx +381 -0
- package/src/ui/UIToastExample.tsx +69 -0
- package/src/ui/UIToastSingle.tsx +279 -0
- package/src/ui/UIViewToggle.tsx +64 -0
- package/src/ui/UserSelector/index.tsx +583 -0
- package/src/ui/index.ts +84 -0
- package/src/ui/upload/image.ts +135 -0
- package/src/ui/upload/index.ts +73 -0
- package/src/ui/upload/submitFile.ts +106 -0
- package/src/ui/upload/submitForm.ts +124 -0
- package/src/ui/upload/upload.err.ts +15 -0
- package/src/ui/upload/upload.ts +250 -0
- package/src/utils/CacheDepartmentInfo.ts +372 -0
- package/src/utils/CacheFileContent.ts +315 -0
- package/src/utils/CachePermissionInfo.ts +235 -0
- package/src/utils/CacheRoleInfo.ts +303 -0
- package/src/utils/CacheUserInfo.ts +818 -0
- package/src/utils/CacheViewToggleInfo.ts +182 -0
- package/src/utils/MessageContentUtils.ts +268 -0
- package/src/utils/clipboard.ts +70 -0
- package/src/utils/companyEmail.ts +19 -0
- package/src/utils/forbidDebugger.ts +59 -0
- package/src/utils/formatters.ts +94 -0
- package/src/utils/htmlUtils.tsx +59 -0
- package/src/utils/index.ts +18 -0
- package/src/utils/keyboard.ts +114 -0
- package/src/utils/mammothApi.ts +16 -0
- package/src/utils/polyfills.ts +928 -0
- package/src/utils/regex.ts +14 -0
- package/src/utils/storage.ts +95 -0
- package/src/utils/workerBootstrap.ts +87 -0
- package/dist/api/index.js +0 -1553
- package/dist/contract.js +0 -5
- package/dist/index.js +0 -4492
- package/dist/types/index.js +0 -49
- package/dist/ui/index.css +0 -1876
- package/dist/ui/index.js +0 -22245
- package/dist/utils/index.js +0 -4296
- /package/{dist → src}/styles/ant-modal.css +0 -0
- /package/{dist → src}/styles/ant-spin.css +0 -0
- /package/{dist → src}/styles/ant-table.css +0 -0
- /package/{dist → src}/styles/index.css +0 -0
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
import React, { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import * as XLSX from 'xlsx';
|
|
3
|
+
import { UniverSheetsCorePreset } from '@univerjs/preset-sheets-core';
|
|
4
|
+
import UniverPresetSheetsCoreZhCN from '@univerjs/preset-sheets-core/locales/zh-CN';
|
|
5
|
+
import { createUniver, LocaleType, mergeLocales } from '@univerjs/presets';
|
|
6
|
+
import '@univerjs/preset-sheets-core/lib/index.css';
|
|
7
|
+
|
|
8
|
+
export interface ExcelPreviewProps {
|
|
9
|
+
file: File | null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
type PreviewStatus = 'loading' | 'ready' | 'error';
|
|
13
|
+
|
|
14
|
+
type UniverAPI = ReturnType<typeof createUniver>['univerAPI'];
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 把 SheetJS 工作簿转换为 Univer 的 IWorkbookData(只读预览用)。
|
|
18
|
+
* 覆盖:多工作表、值类型、公式、合并单元格、列宽行高、隐藏行列/工作表、冻结窗格、日期/数字格式。
|
|
19
|
+
*/
|
|
20
|
+
function convertWorkbook(workbook: XLSX.WorkBook, fileName: string): Record<string, unknown> {
|
|
21
|
+
const sheets: Record<string, unknown> = {};
|
|
22
|
+
const sheetOrder: string[] = [];
|
|
23
|
+
|
|
24
|
+
const hiddenMap = new Map<string, boolean>();
|
|
25
|
+
(workbook.Workbook?.Sheets ?? []).forEach((meta) => {
|
|
26
|
+
if (meta.name) hiddenMap.set(meta.name, Boolean(meta.Hidden));
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
workbook.SheetNames.forEach((sheetName, sheetIndex) => {
|
|
30
|
+
const worksheet = workbook.Sheets[sheetName];
|
|
31
|
+
const sheetId = `sheet-${sheetIndex + 1}`;
|
|
32
|
+
|
|
33
|
+
const cellData: Record<number, Record<number, unknown>> = {};
|
|
34
|
+
let maxRow = 0;
|
|
35
|
+
let maxColumn = 0;
|
|
36
|
+
|
|
37
|
+
// 单元格
|
|
38
|
+
Object.keys(worksheet).forEach((address) => {
|
|
39
|
+
if (address.startsWith('!')) return;
|
|
40
|
+
const cell = worksheet[address] as XLSX.CellObject | undefined;
|
|
41
|
+
if (!cell) return;
|
|
42
|
+
|
|
43
|
+
const { r, c } = XLSX.utils.decode_cell(address);
|
|
44
|
+
maxRow = Math.max(maxRow, r);
|
|
45
|
+
maxColumn = Math.max(maxColumn, c);
|
|
46
|
+
|
|
47
|
+
const univerCell: Record<string, unknown> = {};
|
|
48
|
+
|
|
49
|
+
// 公式(SheetJS 的 f 不含开头的 '=')
|
|
50
|
+
if (typeof cell.f === 'string' && cell.f.length > 0) {
|
|
51
|
+
univerCell.f = cell.f.startsWith('=') ? cell.f.slice(1) : cell.f;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// 值:保留原始类型(数字/布尔/字符串),日期转换为 Excel 序列值
|
|
55
|
+
const rawValue = cell.v;
|
|
56
|
+
if (rawValue !== undefined && rawValue !== null && rawValue !== '') {
|
|
57
|
+
if (cell.t === 'd' && rawValue instanceof Date) {
|
|
58
|
+
univerCell.v = dateToExcelSerial(rawValue);
|
|
59
|
+
univerCell.s = { n: { pattern: normalizeNumfmt(cell.z) || 'yyyy-mm-dd' } };
|
|
60
|
+
} else if (cell.t === 'n' && typeof rawValue === 'number') {
|
|
61
|
+
univerCell.v = rawValue;
|
|
62
|
+
const pattern = normalizeNumfmt(cell.z);
|
|
63
|
+
if (pattern) univerCell.s = { n: { pattern } };
|
|
64
|
+
} else if (cell.t === 'b') {
|
|
65
|
+
univerCell.v = Boolean(rawValue);
|
|
66
|
+
} else {
|
|
67
|
+
univerCell.v = String(rawValue);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (Object.keys(univerCell).length > 0) {
|
|
72
|
+
if (!cellData[r]) cellData[r] = {};
|
|
73
|
+
cellData[r][c] = univerCell;
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// 合并单元格
|
|
78
|
+
const mergeData = (worksheet['!merges'] ?? []).map((range) => ({
|
|
79
|
+
startRow: range.s.r,
|
|
80
|
+
startColumn: range.s.c,
|
|
81
|
+
endRow: range.e.r,
|
|
82
|
+
endColumn: range.e.c,
|
|
83
|
+
}));
|
|
84
|
+
(worksheet['!merges'] ?? []).forEach((range) => {
|
|
85
|
+
maxRow = Math.max(maxRow, range.e.r);
|
|
86
|
+
maxColumn = Math.max(maxColumn, range.e.c);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// 列宽
|
|
90
|
+
const columnData: Record<number, { w?: number; hd?: 1 }> = {};
|
|
91
|
+
(worksheet['!cols'] ?? []).forEach((col, index) => {
|
|
92
|
+
const column: { w?: number; hd?: 1 } = {};
|
|
93
|
+
if (typeof col.wpx === 'number') column.w = Math.round(col.wpx);
|
|
94
|
+
else if (typeof col.wch === 'number') column.w = Math.round(col.wch * 7 + 5);
|
|
95
|
+
if (col.hidden) column.hd = 1;
|
|
96
|
+
if (column.w !== undefined || column.hd !== undefined) columnData[index] = column;
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// 行高
|
|
100
|
+
const rowData: Record<number, { h?: number; hd?: 1 }> = {};
|
|
101
|
+
(worksheet['!rows'] ?? []).forEach((row, index) => {
|
|
102
|
+
const rowInfo: { h?: number; hd?: 1 } = {};
|
|
103
|
+
if (typeof row.hpx === 'number') rowInfo.h = Math.round(row.hpx);
|
|
104
|
+
else if (typeof row.hpt === 'number') rowInfo.h = Math.round((row.hpt * 96) / 72);
|
|
105
|
+
if (row.hidden) rowInfo.hd = 1;
|
|
106
|
+
if (rowInfo.h !== undefined || rowInfo.hd !== undefined) rowData[index] = rowInfo;
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// 冻结窗格(部分格式/版本下 SheetJS 可能不提供)
|
|
110
|
+
const freezePane = (worksheet as XLSX.WorkSheet & {
|
|
111
|
+
'!freeze'?: { xSplit?: number; ySplit?: number; topLeftCell?: string };
|
|
112
|
+
})['!freeze'];
|
|
113
|
+
let freeze: { xSplit: number; ySplit: number; startRow: number; startColumn: number } | undefined;
|
|
114
|
+
if (freezePane && (freezePane.xSplit || freezePane.ySplit)) {
|
|
115
|
+
let startRow = freezePane.ySplit ?? 0;
|
|
116
|
+
let startColumn = freezePane.xSplit ?? 0;
|
|
117
|
+
if (freezePane.topLeftCell) {
|
|
118
|
+
const anchor = XLSX.utils.decode_cell(freezePane.topLeftCell);
|
|
119
|
+
startRow = anchor.r;
|
|
120
|
+
startColumn = anchor.c;
|
|
121
|
+
}
|
|
122
|
+
freeze = {
|
|
123
|
+
xSplit: freezePane.xSplit ?? 0,
|
|
124
|
+
ySplit: freezePane.ySplit ?? 0,
|
|
125
|
+
startRow,
|
|
126
|
+
startColumn,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// 实际使用范围(!ref 可能比已解析单元格更大)
|
|
131
|
+
let refMaxRow = maxRow;
|
|
132
|
+
let refMaxColumn = maxColumn;
|
|
133
|
+
if (worksheet['!ref']) {
|
|
134
|
+
const range = XLSX.utils.decode_range(worksheet['!ref']);
|
|
135
|
+
refMaxRow = Math.max(refMaxRow, range.e.r);
|
|
136
|
+
refMaxColumn = Math.max(refMaxColumn, range.e.c);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
sheets[sheetId] = {
|
|
140
|
+
id: sheetId,
|
|
141
|
+
name: sheetName,
|
|
142
|
+
hidden: hiddenMap.get(sheetName) ? 1 : 0,
|
|
143
|
+
...(freeze ? { freeze } : {}),
|
|
144
|
+
rowCount: Math.max(refMaxRow + 1, 100),
|
|
145
|
+
columnCount: Math.max(refMaxColumn + 1, 26),
|
|
146
|
+
defaultColumnWidth: 88,
|
|
147
|
+
defaultRowHeight: 24,
|
|
148
|
+
showGridlines: 1,
|
|
149
|
+
rowHeader: { width: 46 },
|
|
150
|
+
columnHeader: { height: 22 },
|
|
151
|
+
mergeData,
|
|
152
|
+
cellData,
|
|
153
|
+
rowData,
|
|
154
|
+
columnData,
|
|
155
|
+
};
|
|
156
|
+
sheetOrder.push(sheetId);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
id: `workbook-preview-${Date.now()}`,
|
|
161
|
+
name: fileName,
|
|
162
|
+
sheetOrder,
|
|
163
|
+
sheets,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** JS Date 转 Excel 1900 日期系统的序列值(兼容 Excel 1900 闰年 Bug)。 */
|
|
168
|
+
function dateToExcelSerial(date: Date): number {
|
|
169
|
+
const epoch = Date.UTC(1899, 11, 30);
|
|
170
|
+
const current = Date.UTC(
|
|
171
|
+
date.getFullYear(),
|
|
172
|
+
date.getMonth(),
|
|
173
|
+
date.getDate(),
|
|
174
|
+
date.getHours(),
|
|
175
|
+
date.getMinutes(),
|
|
176
|
+
date.getSeconds(),
|
|
177
|
+
);
|
|
178
|
+
return (current - epoch) / 86_400_000;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Excel 内置数字格式 ID(数字形式 z)中常见的日期/时间格式。 */
|
|
182
|
+
const BUILTIN_NUMFMT: Record<number, string> = {
|
|
183
|
+
14: 'yyyy/m/d',
|
|
184
|
+
15: 'd-mmm-yy',
|
|
185
|
+
16: 'd-mmm',
|
|
186
|
+
17: 'mmm-yy',
|
|
187
|
+
18: 'h:mm AM/PM',
|
|
188
|
+
19: 'h:mm:ss AM/PM',
|
|
189
|
+
20: 'h:mm',
|
|
190
|
+
21: 'h:mm:ss',
|
|
191
|
+
22: 'yyyy/m/d h:mm',
|
|
192
|
+
27: 'yyyy-mm-dd',
|
|
193
|
+
28: 'mmm-dd',
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* 归一化数字格式:General 等通用格式无需写入 Univer;
|
|
198
|
+
* 内置格式 ID 映射为日期/时间模式;其余 Excel 格式串与 Univer 基本兼容,直接透传。
|
|
199
|
+
*/
|
|
200
|
+
function normalizeNumfmt(pattern?: string | number): string | undefined {
|
|
201
|
+
if (pattern === undefined || pattern === null) return undefined;
|
|
202
|
+
if (typeof pattern === 'number') return BUILTIN_NUMFMT[pattern];
|
|
203
|
+
const trimmed = pattern.trim();
|
|
204
|
+
if (!trimmed || trimmed.toLowerCase() === 'general') return undefined;
|
|
205
|
+
return trimmed;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export const ExcelPreview: React.FC<ExcelPreviewProps> = ({ file }) => {
|
|
209
|
+
const [status, setStatus] = useState<PreviewStatus>('loading');
|
|
210
|
+
const [retryTick, setRetryTick] = useState(0);
|
|
211
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
212
|
+
|
|
213
|
+
useEffect(() => {
|
|
214
|
+
if (!file || !containerRef.current) return;
|
|
215
|
+
|
|
216
|
+
let disposed = false;
|
|
217
|
+
let univerAPI: UniverAPI | null = null;
|
|
218
|
+
|
|
219
|
+
setStatus('loading');
|
|
220
|
+
|
|
221
|
+
(async () => {
|
|
222
|
+
try {
|
|
223
|
+
const arrayBuffer = await file.arrayBuffer();
|
|
224
|
+
// cellDates: 日期单元格解析为 JS Date;cellNF: 保留数字格式串到 cell.z
|
|
225
|
+
const workbook = XLSX.read(arrayBuffer, { type: 'array', cellDates: true, cellNF: true });
|
|
226
|
+
const workbookData = convertWorkbook(workbook, file.name);
|
|
227
|
+
|
|
228
|
+
if (disposed || !containerRef.current) return;
|
|
229
|
+
|
|
230
|
+
const instance = createUniver({
|
|
231
|
+
locale: LocaleType.ZH_CN,
|
|
232
|
+
locales: {
|
|
233
|
+
[LocaleType.ZH_CN]: mergeLocales(UniverPresetSheetsCoreZhCN),
|
|
234
|
+
},
|
|
235
|
+
presets: [
|
|
236
|
+
UniverSheetsCorePreset({
|
|
237
|
+
container: containerRef.current,
|
|
238
|
+
header: true,
|
|
239
|
+
toolbar: true,
|
|
240
|
+
formulaBar: true,
|
|
241
|
+
contextMenu: true,
|
|
242
|
+
}),
|
|
243
|
+
],
|
|
244
|
+
});
|
|
245
|
+
univerAPI = instance.univerAPI;
|
|
246
|
+
|
|
247
|
+
if (disposed) {
|
|
248
|
+
univerAPI.dispose();
|
|
249
|
+
univerAPI = null;
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const univerWorkbook = univerAPI.createWorkbook(workbookData as any);
|
|
254
|
+
// 只读预览:禁止编辑,保留选中、滚动、多工作表切换
|
|
255
|
+
univerWorkbook.setEditable(false);
|
|
256
|
+
|
|
257
|
+
setStatus('ready');
|
|
258
|
+
} catch (error) {
|
|
259
|
+
console.error('[ExcelPreview] 文档加载失败:', error);
|
|
260
|
+
if (!disposed) setStatus('error');
|
|
261
|
+
}
|
|
262
|
+
})();
|
|
263
|
+
|
|
264
|
+
return () => {
|
|
265
|
+
disposed = true;
|
|
266
|
+
if (univerAPI) {
|
|
267
|
+
univerAPI.dispose();
|
|
268
|
+
univerAPI = null;
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
}, [file, retryTick]);
|
|
272
|
+
|
|
273
|
+
return (
|
|
274
|
+
<div className="ui-file-preview-excel" data-global-context-menu-ignore="true">
|
|
275
|
+
<style>
|
|
276
|
+
{`.excel-loading-overlay {
|
|
277
|
+
position: absolute;
|
|
278
|
+
top: 0;
|
|
279
|
+
left: 0;
|
|
280
|
+
right: 0;
|
|
281
|
+
bottom: 0;
|
|
282
|
+
background: #ffffff;
|
|
283
|
+
display: flex;
|
|
284
|
+
flex-direction: column;
|
|
285
|
+
align-items: center;
|
|
286
|
+
justify-content: center;
|
|
287
|
+
z-index: 100;
|
|
288
|
+
}
|
|
289
|
+
.excel-loading-spinner {
|
|
290
|
+
width: 40px;
|
|
291
|
+
height: 40px;
|
|
292
|
+
border: 3px solid #f3f3f3;
|
|
293
|
+
border-top: 3px solid #5292f7;
|
|
294
|
+
border-radius: 50%;
|
|
295
|
+
animation: excel-spin 1s linear infinite;
|
|
296
|
+
}
|
|
297
|
+
.excel-loading-text {
|
|
298
|
+
margin-top: 16px;
|
|
299
|
+
color: #666;
|
|
300
|
+
font-size: 14px;
|
|
301
|
+
}
|
|
302
|
+
.excel-loading-error-icon {
|
|
303
|
+
font-size: 48px;
|
|
304
|
+
color: #f56c6c;
|
|
305
|
+
}
|
|
306
|
+
.excel-loading-error-text {
|
|
307
|
+
margin-top: 16px;
|
|
308
|
+
color: #f56c6c;
|
|
309
|
+
font-size: 14px;
|
|
310
|
+
}
|
|
311
|
+
.excel-loading-retry-btn {
|
|
312
|
+
margin-top: 20px;
|
|
313
|
+
padding: 8px 24px;
|
|
314
|
+
background: #5292f7;
|
|
315
|
+
color: #fff;
|
|
316
|
+
border: none;
|
|
317
|
+
border-radius: 4px;
|
|
318
|
+
cursor: pointer;
|
|
319
|
+
font-size: 14px;
|
|
320
|
+
}
|
|
321
|
+
.excel-loading-retry-btn:hover {
|
|
322
|
+
background: #67b8ff;
|
|
323
|
+
}
|
|
324
|
+
@keyframes excel-spin {
|
|
325
|
+
0% { transform: rotate(0deg); }
|
|
326
|
+
100% { transform: rotate(360deg); }
|
|
327
|
+
}`}
|
|
328
|
+
</style>
|
|
329
|
+
{file && status !== 'ready' && (
|
|
330
|
+
<div className="excel-loading-overlay">
|
|
331
|
+
{status === 'error' ? (
|
|
332
|
+
<>
|
|
333
|
+
<div className="excel-loading-error-icon">✕</div>
|
|
334
|
+
<div className="excel-loading-error-text">文档加载失败</div>
|
|
335
|
+
<button
|
|
336
|
+
className="excel-loading-retry-btn"
|
|
337
|
+
onClick={() => setRetryTick((tick) => tick + 1)}
|
|
338
|
+
>
|
|
339
|
+
重新加载
|
|
340
|
+
</button>
|
|
341
|
+
</>
|
|
342
|
+
) : (
|
|
343
|
+
<>
|
|
344
|
+
<div className="excel-loading-spinner"></div>
|
|
345
|
+
<div className="excel-loading-text">加载中...</div>
|
|
346
|
+
</>
|
|
347
|
+
)}
|
|
348
|
+
</div>
|
|
349
|
+
)}
|
|
350
|
+
{file ? (
|
|
351
|
+
<div key={retryTick} ref={containerRef} style={{ width: '100%', height: '100dvh' }} />
|
|
352
|
+
) : null}
|
|
353
|
+
</div>
|
|
354
|
+
);
|
|
355
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
|
|
3
|
+
export interface ImagePreviewProps {
|
|
4
|
+
containerRef: React.RefObject<HTMLDivElement>;
|
|
5
|
+
type: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export const ImagePreview: React.FC<ImagePreviewProps> = ({ containerRef, type }) => {
|
|
9
|
+
return (
|
|
10
|
+
type === 'image' && (
|
|
11
|
+
<div ref={containerRef}
|
|
12
|
+
style={{
|
|
13
|
+
display: type === 'image'? 'inherit': 'none',
|
|
14
|
+
maxWidth: '100%',
|
|
15
|
+
maxHeight: 'calc(100vh - 40px)',
|
|
16
|
+
objectFit: 'contain'
|
|
17
|
+
}}
|
|
18
|
+
/>
|
|
19
|
+
));
|
|
20
|
+
};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { Dispatch, RefObject, SetStateAction } from 'react';
|
|
2
|
+
import * as febs from 'febs-browser';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 根据 ArrayBuffer 开头的 Magic Bytes 推断图片 MIME 类型。
|
|
6
|
+
* 作为兜底方案:当 Content-Type 响应头/缓存类型都缺失时(尤其 Safari 缓存 Blob.type 丢失后),
|
|
7
|
+
* 用于保证 <img src="blob:..."> 在 Safari 严格模式下仍能正确解码。
|
|
8
|
+
*/
|
|
9
|
+
const detectImageMimeFromBuffer = (ab: ArrayBuffer): string => {
|
|
10
|
+
if (!ab || ab.byteLength < 4) return '';
|
|
11
|
+
const view = new Uint8Array(ab, 0, Math.min(16, ab.byteLength));
|
|
12
|
+
// JPEG: FF D8 FF
|
|
13
|
+
if (view[0] === 0xFF && view[1] === 0xD8 && view[2] === 0xFF) return 'image/jpeg';
|
|
14
|
+
// PNG: 89 50 4E 47
|
|
15
|
+
if (view[0] === 0x89 && view[1] === 0x50 && view[2] === 0x4E && view[3] === 0x47) return 'image/png';
|
|
16
|
+
// GIF: 47 49 46 38
|
|
17
|
+
if (view[0] === 0x47 && view[1] === 0x49 && view[2] === 0x46 && view[3] === 0x38) return 'image/gif';
|
|
18
|
+
// WEBP: 52 49 46 46 .. .. .. .. 57 45 42 50 ("RIFF" + ... + "WEBP")
|
|
19
|
+
if (view.length >= 12
|
|
20
|
+
&& view[0] === 0x52 && view[1] === 0x49 && view[2] === 0x46 && view[3] === 0x46
|
|
21
|
+
&& view[8] === 0x57 && view[9] === 0x45 && view[10] === 0x42 && view[11] === 0x50) return 'image/webp';
|
|
22
|
+
// BMP: 42 4D
|
|
23
|
+
if (view[0] === 0x42 && view[1] === 0x4D) return 'image/bmp';
|
|
24
|
+
// SVG: <svg 开头的文本(text/xml 或 image/svg+xml)
|
|
25
|
+
try {
|
|
26
|
+
const head = new TextDecoder('utf-8').decode(view.slice(0, 16)).trimStart();
|
|
27
|
+
if (head.startsWith('<svg') || head.startsWith('<?xml')) return 'image/svg+xml';
|
|
28
|
+
} catch { /* ignore decode errors */ }
|
|
29
|
+
return '';
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const loadImagePreview = async (
|
|
33
|
+
buffer: {
|
|
34
|
+
buffer: ArrayBuffer;
|
|
35
|
+
headers: any;
|
|
36
|
+
status: number;
|
|
37
|
+
},
|
|
38
|
+
imageRef: RefObject<HTMLDivElement>,
|
|
39
|
+
) => {
|
|
40
|
+
const img = new Image();
|
|
41
|
+
// 优先 headers → 再用 Magic Bytes 兜底(Safari Blob/IDB 的 Content-Type 可能被清空)
|
|
42
|
+
let mimeType = buffer.headers?.get?.('Content-Type') || '';
|
|
43
|
+
if (!mimeType) {
|
|
44
|
+
mimeType = detectImageMimeFromBuffer(buffer.buffer);
|
|
45
|
+
}
|
|
46
|
+
const blob = new Blob([buffer.buffer], { type: mimeType });
|
|
47
|
+
img.src = URL.createObjectURL(blob);
|
|
48
|
+
img.onload = () => {
|
|
49
|
+
img.style.maxWidth = '100%';
|
|
50
|
+
img.style.maxHeight = 'calc(100vh - 40px)';
|
|
51
|
+
img.style.objectFit = 'contain';
|
|
52
|
+
|
|
53
|
+
(imageRef.current as any).innerHTML = '';
|
|
54
|
+
(imageRef.current as any).appendChild(img);
|
|
55
|
+
URL.revokeObjectURL(img.src);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export const destroyImagePreview = (imageRef: RefObject<HTMLDivElement>) => {
|
|
60
|
+
// if (imageRef.current) {
|
|
61
|
+
// (imageRef.current as any).innerHTML = '';
|
|
62
|
+
// }
|
|
63
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
|
|
2
|
+
import { FileViewer } from '@file-viewer/react';
|
|
3
|
+
import { pdfRenderer } from '@file-viewer/renderer-pdf';
|
|
4
|
+
|
|
5
|
+
/** localStorage 存储前缀 */
|
|
6
|
+
const PROGRESS_STORAGE_PREFIX = 'reading-progress:';
|
|
7
|
+
|
|
8
|
+
/** 防抖保存间隔(ms) */
|
|
9
|
+
const SAVE_DEBOUNCE_MS = 500;
|
|
10
|
+
|
|
11
|
+
/** 阅读进度记录 */
|
|
12
|
+
interface ReadingProgressRecord {
|
|
13
|
+
state: Record<string, unknown>;
|
|
14
|
+
savedAt: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const readProgress = (key: string): Record<string, unknown> | undefined => {
|
|
18
|
+
try {
|
|
19
|
+
const raw = localStorage.getItem(PROGRESS_STORAGE_PREFIX + key);
|
|
20
|
+
if (raw) {
|
|
21
|
+
const parsed = JSON.parse(raw) as ReadingProgressRecord;
|
|
22
|
+
return parsed.state;
|
|
23
|
+
}
|
|
24
|
+
} catch {
|
|
25
|
+
// ignore
|
|
26
|
+
}
|
|
27
|
+
return undefined;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const writeProgress = (key: string, state: Record<string, unknown>) => {
|
|
31
|
+
try {
|
|
32
|
+
const record: ReadingProgressRecord = { state, savedAt: Date.now() };
|
|
33
|
+
localStorage.setItem(PROGRESS_STORAGE_PREFIX + key, JSON.stringify(record));
|
|
34
|
+
} catch {
|
|
35
|
+
// ignore
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export interface PdfPreviewProps {
|
|
40
|
+
file: File | null;
|
|
41
|
+
/** 阅读进度本地存储 key(通常为文件 ID);未提供时不保存/恢复进度 */
|
|
42
|
+
progressKey?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const PdfPreview: React.FC<PdfPreviewProps> = ({ file, progressKey }) => {
|
|
46
|
+
const latestStateRef = useRef<Record<string, unknown> | null>(null);
|
|
47
|
+
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
48
|
+
const progressKeyRef = useRef(progressKey);
|
|
49
|
+
progressKeyRef.current = progressKey;
|
|
50
|
+
|
|
51
|
+
const initialViewState = useMemo(() => {
|
|
52
|
+
if (!progressKey) return undefined;
|
|
53
|
+
return readProgress(progressKey);
|
|
54
|
+
}, [progressKey]);
|
|
55
|
+
|
|
56
|
+
const onEvent = useCallback((event: { type: string; payload?: unknown }) => {
|
|
57
|
+
if (event.type !== 'view-state-change' || !progressKey) return;
|
|
58
|
+
const change = event.payload as { state?: Record<string, unknown> } | undefined;
|
|
59
|
+
if (change?.state) {
|
|
60
|
+
latestStateRef.current = change.state;
|
|
61
|
+
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
|
62
|
+
saveTimerRef.current = setTimeout(() => {
|
|
63
|
+
if (latestStateRef.current) writeProgress(progressKey, latestStateRef.current);
|
|
64
|
+
}, SAVE_DEBOUNCE_MS);
|
|
65
|
+
}
|
|
66
|
+
}, [progressKey]);
|
|
67
|
+
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
return () => {
|
|
70
|
+
if (saveTimerRef.current) {
|
|
71
|
+
clearTimeout(saveTimerRef.current);
|
|
72
|
+
saveTimerRef.current = null;
|
|
73
|
+
}
|
|
74
|
+
const key = progressKeyRef.current;
|
|
75
|
+
if (key && latestStateRef.current) {
|
|
76
|
+
writeProgress(key, latestStateRef.current);
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
}, []);
|
|
80
|
+
|
|
81
|
+
const pdfRenderers = [pdfRenderer];
|
|
82
|
+
|
|
83
|
+
const options = useMemo(() => ({
|
|
84
|
+
locale: 'zh-CN',
|
|
85
|
+
rendererMode: 'replace' as const,
|
|
86
|
+
renderers: pdfRenderers as any,
|
|
87
|
+
theme: 'light' as const,
|
|
88
|
+
toolbar: {
|
|
89
|
+
position: 'bottom-right' as const,
|
|
90
|
+
download: false,
|
|
91
|
+
print: false,
|
|
92
|
+
exportHtml: false,
|
|
93
|
+
},
|
|
94
|
+
pdf: {
|
|
95
|
+
cMapUrl: '/cmaps/',
|
|
96
|
+
defaultNavigationVisible: false,
|
|
97
|
+
},
|
|
98
|
+
initialViewState,
|
|
99
|
+
}), [initialViewState]);
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
<div className="ui-file-preview-pdf" data-global-context-menu-ignore="true">
|
|
103
|
+
<style>
|
|
104
|
+
{`.pdf-toolbar {
|
|
105
|
+
padding-right: 50px !important;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/* 文本层提升到 canvas 之上,确保鼠标事件被文本层捕获 */
|
|
109
|
+
.ui-file-preview-pdf .pdfViewer .page .textLayer {
|
|
110
|
+
z-index: 2 !important;
|
|
111
|
+
pointer-events: auto !important;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/* 强制覆盖权限模式和通用 user-select 规则 */
|
|
115
|
+
.ui-file-preview-pdf .pdfViewer .textLayer span,
|
|
116
|
+
.ui-file-preview-pdf .pdfViewer .textLayer br,
|
|
117
|
+
.ui-file-preview-pdf .pdfViewer.enablePermissions .textLayer span {
|
|
118
|
+
-webkit-user-select: text !important;
|
|
119
|
+
-moz-user-select: text !important;
|
|
120
|
+
user-select: text !important;
|
|
121
|
+
cursor: text !important;
|
|
122
|
+
pointer-events: auto !important;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/* 恢复选择高亮背景(PDF.js 默认设为透明导致选中时看不到) */
|
|
126
|
+
.ui-file-preview-pdf .pdfViewer .textLayer ::selection,
|
|
127
|
+
.ui-file-preview-pdf .pdfViewer .textLayer span::selection,
|
|
128
|
+
.ui-file-preview-pdf .pdfViewer .textLayer br::selection {
|
|
129
|
+
background: highlight !important;
|
|
130
|
+
}
|
|
131
|
+
.ui-file-preview-pdf .pdfViewer .textLayer ::-moz-selection,
|
|
132
|
+
.ui-file-preview-pdf .pdfViewer .textLayer span::-moz-selection,
|
|
133
|
+
.ui-file-preview-pdf .pdfViewer .textLayer br::-moz-selection {
|
|
134
|
+
background: highlight !important;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/* 父级元素 user-select 统一放开 */
|
|
138
|
+
.ui-file-preview-pdf .pdfViewer,
|
|
139
|
+
.ui-file-preview-pdf .pdfViewer .page,
|
|
140
|
+
.ui-file-preview-pdf .pdfViewer .textLayer {
|
|
141
|
+
-webkit-user-select: text !important;
|
|
142
|
+
-moz-user-select: text !important;
|
|
143
|
+
user-select: text !important;
|
|
144
|
+
}`}
|
|
145
|
+
</style>
|
|
146
|
+
{file ? (
|
|
147
|
+
<FileViewer
|
|
148
|
+
file={file}
|
|
149
|
+
name={file.name}
|
|
150
|
+
options={options}
|
|
151
|
+
onEvent={onEvent}
|
|
152
|
+
/>
|
|
153
|
+
) : null}
|
|
154
|
+
</div>
|
|
155
|
+
);
|
|
156
|
+
};
|