@lotics/ui 1.14.0 → 1.16.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/package.json +16 -1
- package/src/combobox.tsx +13 -1
- package/src/custom_option.test.ts +50 -0
- package/src/custom_option.ts +30 -0
- package/src/file_gallery_modal.tsx +22 -40
- package/src/file_preview.tsx +38 -0
- package/src/file_preview.web.tsx +198 -0
- package/src/file_preview_types.ts +35 -0
- package/src/menu_list_item.tsx +43 -3
- package/src/mime.ts +46 -0
- package/src/picker_menu.tsx +29 -5
- package/src/search_select.tsx +47 -47
- package/src/spreadsheet_view.tsx +304 -0
- package/src/table.tsx +105 -36
package/src/mime.ts
CHANGED
|
@@ -13,3 +13,49 @@ export function isVideoMimeType(mimeType: string): boolean {
|
|
|
13
13
|
export function isAudioMimeType(mimeType: string): boolean {
|
|
14
14
|
return mimeType.toLowerCase().startsWith("audio/");
|
|
15
15
|
}
|
|
16
|
+
|
|
17
|
+
const EXCEL_MIME_TYPES = [
|
|
18
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
19
|
+
"application/wps-office.xlsx",
|
|
20
|
+
"application/wps-office.xls",
|
|
21
|
+
"application/vnd.ms-excel",
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
const LEGACY_EXCEL_MIME_TYPES = ["application/vnd.ms-excel", "application/wps-office.xls"];
|
|
25
|
+
|
|
26
|
+
const DOCX_MIME_TYPES = [
|
|
27
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
28
|
+
"application/wps-office.docx",
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
export function isPdfMimeType(mimeType: string): boolean {
|
|
32
|
+
return mimeType.toLowerCase() === "application/pdf";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function isExcelMimeType(mimeType: string): boolean {
|
|
36
|
+
return EXCEL_MIME_TYPES.includes(mimeType.toLowerCase());
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Legacy .xls (BIFF) — not parseable in-app; excluded from previewable. */
|
|
40
|
+
export function isLegacyExcelMimeType(mimeType: string): boolean {
|
|
41
|
+
return LEGACY_EXCEL_MIME_TYPES.includes(mimeType.toLowerCase());
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function isCsvMimeType(mimeType: string): boolean {
|
|
45
|
+
const mt = mimeType.toLowerCase();
|
|
46
|
+
return mt === "text/csv" || mt === "text/tab-separated-values";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function isDocxMimeType(mimeType: string): boolean {
|
|
50
|
+
return DOCX_MIME_TYPES.includes(mimeType.toLowerCase());
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** PDF, modern Excel (.xlsx), Word (.docx), or CSV — types FilePreview renders inline. */
|
|
54
|
+
export function isPreviewableMimeType(mimeType: string): boolean {
|
|
55
|
+
return (
|
|
56
|
+
(isExcelMimeType(mimeType) && !isLegacyExcelMimeType(mimeType)) ||
|
|
57
|
+
isCsvMimeType(mimeType) ||
|
|
58
|
+
isPdfMimeType(mimeType) ||
|
|
59
|
+
isDocxMimeType(mimeType)
|
|
60
|
+
);
|
|
61
|
+
}
|
package/src/picker_menu.tsx
CHANGED
|
@@ -10,6 +10,7 @@ import { MenuButton } from "./menu_button";
|
|
|
10
10
|
import { ActivityIndicator } from "./activity_indicator";
|
|
11
11
|
import { PickerOption, PickerValue, PickerOnValueChange, PickerOnClose } from "./picker";
|
|
12
12
|
import { useScreenSize } from "./use_screen_size";
|
|
13
|
+
import { customOptionFor } from "./custom_option";
|
|
13
14
|
|
|
14
15
|
export interface PickerMenuProps<T extends string = string, MULTI extends boolean = false> {
|
|
15
16
|
testID?: string;
|
|
@@ -22,6 +23,14 @@ export interface PickerMenuProps<T extends string = string, MULTI extends boolea
|
|
|
22
23
|
renderOptionContent?: (option: PickerOption<T>) => React.ReactNode;
|
|
23
24
|
/** Enable search input to filter options */
|
|
24
25
|
enableSearch?: boolean;
|
|
26
|
+
/** Single-select + search only: when the typed query matches no option, append
|
|
27
|
+
* a trailing row that selects the raw query as a custom value (free entry).
|
|
28
|
+
* `onValueChange` then receives the typed string. */
|
|
29
|
+
allowCustom?: boolean;
|
|
30
|
+
/** Label for the free-entry row (default: the raw query). Return `null` to
|
|
31
|
+
* suppress the row for a given query — e.g. while it is still incomplete or
|
|
32
|
+
* invalid — so "add" only appears when it is a meaningful thing to add. */
|
|
33
|
+
customOptionLabel?: (query: string) => string | null;
|
|
25
34
|
enableSelectAll?: boolean;
|
|
26
35
|
includeEmptyOption?: boolean;
|
|
27
36
|
/** Called whenever the search text changes. Provide it to drive server-side
|
|
@@ -61,6 +70,8 @@ export function PickerMenu<T extends string, MULTI extends boolean = false>(
|
|
|
61
70
|
onRequestClose,
|
|
62
71
|
enableSelectAll,
|
|
63
72
|
enableSearch,
|
|
73
|
+
allowCustom,
|
|
74
|
+
customOptionLabel,
|
|
64
75
|
includeEmptyOption,
|
|
65
76
|
onSearchChange,
|
|
66
77
|
serverFiltered,
|
|
@@ -92,6 +103,13 @@ export function PickerMenu<T extends string, MULTI extends boolean = false>(
|
|
|
92
103
|
|
|
93
104
|
const [searchQuery, setSearchQuery] = useState("");
|
|
94
105
|
|
|
106
|
+
// Free entry: a trailing selectable row carrying the raw query as its value
|
|
107
|
+
// (recognised below for distinct styling). See `customOptionFor` for the rules.
|
|
108
|
+
const customOpt = useMemo(
|
|
109
|
+
() => customOptionFor({ allowCustom, multi, query: searchQuery, options, customOptionLabel }),
|
|
110
|
+
[allowCustom, multi, searchQuery, options, customOptionLabel],
|
|
111
|
+
);
|
|
112
|
+
|
|
95
113
|
const filteredOptions = useMemo(() => {
|
|
96
114
|
let result = options;
|
|
97
115
|
// Skip the local filter in server-driven mode — `options` already reflect
|
|
@@ -107,8 +125,11 @@ export function PickerMenu<T extends string, MULTI extends boolean = false>(
|
|
|
107
125
|
};
|
|
108
126
|
result = [emptyOption, ...result];
|
|
109
127
|
}
|
|
128
|
+
if (customOpt) {
|
|
129
|
+
result = [...result, customOpt];
|
|
130
|
+
}
|
|
110
131
|
return result;
|
|
111
|
-
}, [options, enableSearch, searchQuery, includeEmptyOption, serverFiltered]);
|
|
132
|
+
}, [options, enableSearch, searchQuery, includeEmptyOption, serverFiltered, customOpt]);
|
|
112
133
|
|
|
113
134
|
const [focusedIndex, setFocusedIndex] = useState<number>(() => {
|
|
114
135
|
const selectedIndex = filteredOptions.findIndex((opt) => {
|
|
@@ -297,23 +318,26 @@ export function PickerMenu<T extends string, MULTI extends boolean = false>(
|
|
|
297
318
|
? Array.isArray(multiValue) && multiValue.includes(item.value)
|
|
298
319
|
: item.value === singleValue;
|
|
299
320
|
const isFocused = index === focusedIndex;
|
|
321
|
+
const isCustom = !!customOpt && item.value === customOpt.value;
|
|
300
322
|
|
|
301
323
|
return (
|
|
302
324
|
<MenuButton
|
|
303
325
|
key={item.value}
|
|
304
|
-
testID={item.testID || `picker-option-${item.value}`}
|
|
326
|
+
testID={isCustom ? "picker-custom-option" : item.testID || `picker-option-${item.value}`}
|
|
305
327
|
icon={multi ? <Checkbox checked={isSelected} /> : undefined}
|
|
306
328
|
title={
|
|
307
|
-
renderOptionContent ? (
|
|
329
|
+
renderOptionContent && !isCustom ? (
|
|
308
330
|
renderOptionContent(item)
|
|
309
331
|
) : (
|
|
310
|
-
<Text userSelect="none" numberOfLines={1}>
|
|
332
|
+
<Text userSelect="none" numberOfLines={1} color={isCustom ? "zinc-500" : undefined}>
|
|
311
333
|
{item.label}
|
|
312
334
|
</Text>
|
|
313
335
|
)
|
|
314
336
|
}
|
|
315
337
|
right={
|
|
316
|
-
|
|
338
|
+
isCustom ? (
|
|
339
|
+
<Icon name="plus" size={16} color={colors.zinc["400"]} />
|
|
340
|
+
) : !multi && isSelected ? (
|
|
317
341
|
<Icon name="check" size={18} color={colors.zinc["950"]} />
|
|
318
342
|
) : undefined
|
|
319
343
|
}
|
package/src/search_select.tsx
CHANGED
|
@@ -9,9 +9,8 @@ import {
|
|
|
9
9
|
import { useCallback, useEffect, useId, useRef, useState, type ReactNode } from "react";
|
|
10
10
|
import { colors } from "./colors";
|
|
11
11
|
import { Text } from "./text";
|
|
12
|
-
import { Icon } from "./icon";
|
|
13
12
|
import { TextInputField } from "./text_input_field";
|
|
14
|
-
import {
|
|
13
|
+
import { MenuListItem } from "./menu_list_item";
|
|
15
14
|
import { ActivityIndicator } from "./activity_indicator";
|
|
16
15
|
import { Popover, PopoverContent } from "./popover";
|
|
17
16
|
import type { PickerOption } from "./picker";
|
|
@@ -30,13 +29,20 @@ export interface SearchSelectProps<T extends string = string, D = unknown> {
|
|
|
30
29
|
/** Shown — under a heading — when the box is focused but empty (e.g. the
|
|
31
30
|
* output of `useRecents`). Omit for no recents. */
|
|
32
31
|
recentOptions?: PickerOption<T, D>[];
|
|
33
|
-
/**
|
|
34
|
-
*
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
32
|
+
/** Row subtitle, read from the option's `data` payload. The row's structure
|
|
33
|
+
* and height are owned by the component (a MenuListItem) so every result
|
|
34
|
+
* reads consistently — consumers supply data, not markup. */
|
|
35
|
+
getOptionDescription?: (option: PickerOption<T, D>) => string | undefined;
|
|
36
|
+
/** Leading accessory (e.g. a status dot / avatar). */
|
|
37
|
+
renderOptionIcon?: (option: PickerOption<T, D>) => ReactNode;
|
|
38
|
+
/** Trailing accessory (e.g. a `Badge`). */
|
|
39
|
+
renderOptionRight?: (option: PickerOption<T, D>) => ReactNode;
|
|
40
|
+
/** Marks the matching row as the current selection (a highlight). The box
|
|
41
|
+
* itself stays a search affordance — the selection is shown by the host. */
|
|
38
42
|
selectedValue?: T | null;
|
|
39
|
-
/**
|
|
43
|
+
/** True only on the initial load (no results yet) — show a spinner. During
|
|
44
|
+
* revalidation / typing, pass the query's (SWR-style) `loading`, which stays
|
|
45
|
+
* false while previous rows are kept, so the list never blanks. */
|
|
40
46
|
loading?: boolean;
|
|
41
47
|
/** Debounce for `onSearchChange`, in ms. Default 200. */
|
|
42
48
|
searchDebounceMs?: number;
|
|
@@ -53,14 +59,15 @@ export interface SearchSelectProps<T extends string = string, D = unknown> {
|
|
|
53
59
|
style?: StyleProp<ViewStyle>;
|
|
54
60
|
}
|
|
55
61
|
|
|
56
|
-
|
|
62
|
+
// Approximate row height (MenuListItem: title + description) for scroll-into-view.
|
|
63
|
+
const OPTION_HEIGHT = 52;
|
|
57
64
|
|
|
58
65
|
/**
|
|
59
|
-
* Search-first combobox: an always-visible search
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
* fires `onValueChange` and clears the box for the next search —
|
|
63
|
-
* itself is rendered by the host (a summary above, details below).
|
|
66
|
+
* Search-first combobox: an always-visible white search panel whose typing
|
|
67
|
+
* drives a debounced, server-side search; results drop into a Popover listbox
|
|
68
|
+
* of MenuListItem rows below. Focused while empty, it offers `recentOptions`.
|
|
69
|
+
* Picking a row fires `onValueChange` and clears the box for the next search —
|
|
70
|
+
* the selection itself is rendered by the host (a summary above, details below).
|
|
64
71
|
*
|
|
65
72
|
* Focus stays on the input the whole time (Popover `manageFocus={false}`); the
|
|
66
73
|
* input owns the keyboard (↑/↓ move the active row via `aria-activedescendant`,
|
|
@@ -74,7 +81,9 @@ export function SearchSelect<T extends string = string, D = unknown>(
|
|
|
74
81
|
onSearchChange,
|
|
75
82
|
onValueChange,
|
|
76
83
|
recentOptions,
|
|
77
|
-
|
|
84
|
+
getOptionDescription,
|
|
85
|
+
renderOptionIcon,
|
|
86
|
+
renderOptionRight,
|
|
78
87
|
selectedValue = null,
|
|
79
88
|
loading = false,
|
|
80
89
|
searchDebounceMs = 200,
|
|
@@ -185,6 +194,7 @@ export function SearchSelect<T extends string = string, D = unknown>(
|
|
|
185
194
|
autoFocus={autoFocus}
|
|
186
195
|
autoCapitalize="none"
|
|
187
196
|
autoCorrect={false}
|
|
197
|
+
style={styles.input}
|
|
188
198
|
role="combobox"
|
|
189
199
|
aria-expanded={showList}
|
|
190
200
|
aria-controls={listboxId}
|
|
@@ -224,7 +234,7 @@ export function SearchSelect<T extends string = string, D = unknown>(
|
|
|
224
234
|
// react-native-web forwards it verbatim for assistive tech.
|
|
225
235
|
role={"listbox" as "list"}
|
|
226
236
|
>
|
|
227
|
-
{loading
|
|
237
|
+
{loading ? (
|
|
228
238
|
<View style={styles.statusRow}>
|
|
229
239
|
<ActivityIndicator />
|
|
230
240
|
</View>
|
|
@@ -235,37 +245,23 @@ export function SearchSelect<T extends string = string, D = unknown>(
|
|
|
235
245
|
</Text>
|
|
236
246
|
</View>
|
|
237
247
|
) : (
|
|
238
|
-
list.map((opt, i) =>
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
accessibilityLabel={opt.label ?? opt.value}
|
|
256
|
-
focused={i === activeIndex}
|
|
257
|
-
selected={isSelected}
|
|
258
|
-
right={
|
|
259
|
-
isSelected ? (
|
|
260
|
-
<Icon name="check" size={18} color={colors.zinc["950"]} />
|
|
261
|
-
) : undefined
|
|
262
|
-
}
|
|
263
|
-
disabled={opt.disabled}
|
|
264
|
-
onPress={() => handleSelect(i)}
|
|
265
|
-
onHoverIn={() => setActiveIndex(i)}
|
|
266
|
-
/>
|
|
267
|
-
);
|
|
268
|
-
})
|
|
248
|
+
list.map((opt, i) => (
|
|
249
|
+
<MenuListItem
|
|
250
|
+
key={opt.value}
|
|
251
|
+
nativeID={optionId(i)}
|
|
252
|
+
testID={`search-option-${opt.value}`}
|
|
253
|
+
role="option"
|
|
254
|
+
icon={renderOptionIcon?.(opt)}
|
|
255
|
+
title={opt.label ?? opt.value}
|
|
256
|
+
description={getOptionDescription?.(opt)}
|
|
257
|
+
right={renderOptionRight?.(opt)}
|
|
258
|
+
focused={i === activeIndex}
|
|
259
|
+
selected={opt.value === selectedValue}
|
|
260
|
+
disabled={opt.disabled}
|
|
261
|
+
onPress={() => handleSelect(i)}
|
|
262
|
+
onHoverIn={() => setActiveIndex(i)}
|
|
263
|
+
/>
|
|
264
|
+
))
|
|
269
265
|
)}
|
|
270
266
|
</ScrollView>
|
|
271
267
|
</View>
|
|
@@ -276,6 +272,10 @@ export function SearchSelect<T extends string = string, D = unknown>(
|
|
|
276
272
|
}
|
|
277
273
|
|
|
278
274
|
const styles = StyleSheet.create({
|
|
275
|
+
input: {
|
|
276
|
+
backgroundColor: colors.background,
|
|
277
|
+
boxShadow: colors.border_shadow,
|
|
278
|
+
},
|
|
279
279
|
menu: {
|
|
280
280
|
gap: 2,
|
|
281
281
|
},
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { View, StyleSheet, Pressable } from "react-native";
|
|
3
|
+
import { Text } from "./text";
|
|
4
|
+
import { Button } from "./button";
|
|
5
|
+
import { colors } from "./colors";
|
|
6
|
+
import { isExcelMimeType } from "./mime";
|
|
7
|
+
import { downloadFileFromUrl } from "./download";
|
|
8
|
+
import { type PreviewLabels } from "./file_preview_types";
|
|
9
|
+
import type { DisplayFile } from "./file_thumbnail";
|
|
10
|
+
import type { WorkbookModel } from "@lotics/xlsx/workbook_model";
|
|
11
|
+
import type { RenderConfig } from "@lotics/xlsx/canvas_renderer";
|
|
12
|
+
import type { MergedCellRange } from "@lotics/xlsx/types";
|
|
13
|
+
import type { LayoutConfig } from "@lotics/xlsx/canvas_layout";
|
|
14
|
+
|
|
15
|
+
// All of @lotics/xlsx (parser, formula engine, canvas renderer) is dynamic-
|
|
16
|
+
// imported so a consumer that never opens a spreadsheet doesn't bundle the
|
|
17
|
+
// engine. Only `import type` is static here. Functions are captured on mount.
|
|
18
|
+
interface XlsxApi {
|
|
19
|
+
loadWorkbookFromSnapshot: typeof import("@lotics/xlsx/workbook_model").loadWorkbookFromSnapshot;
|
|
20
|
+
recomputeIfStale: typeof import("@lotics/xlsx/formula_engine").recomputeIfStale;
|
|
21
|
+
drawSpreadsheet: typeof import("@lotics/xlsx/canvas_renderer").drawSpreadsheet;
|
|
22
|
+
LayoutEngine: typeof import("@lotics/xlsx/canvas_layout").LayoutEngine;
|
|
23
|
+
rowColToRef: typeof import("@lotics/xlsx/excel_utils").rowColToRef;
|
|
24
|
+
refToRowCol: typeof import("@lotics/xlsx/excel_utils").refToRowCol;
|
|
25
|
+
parseExcelBuffer: typeof import("@lotics/xlsx/excel_parser").parseExcelBuffer;
|
|
26
|
+
parseCsvFile: typeof import("@lotics/xlsx/csv_parser").parseCsvFile;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function loadXlsx(): Promise<XlsxApi> {
|
|
30
|
+
const [model, formula, renderer, layout, utils, excel, csv] = await Promise.all([
|
|
31
|
+
import("@lotics/xlsx/workbook_model"),
|
|
32
|
+
import("@lotics/xlsx/formula_engine"),
|
|
33
|
+
import("@lotics/xlsx/canvas_renderer"),
|
|
34
|
+
import("@lotics/xlsx/canvas_layout"),
|
|
35
|
+
import("@lotics/xlsx/excel_utils"),
|
|
36
|
+
import("@lotics/xlsx/excel_parser"),
|
|
37
|
+
import("@lotics/xlsx/csv_parser"),
|
|
38
|
+
]);
|
|
39
|
+
return {
|
|
40
|
+
loadWorkbookFromSnapshot: model.loadWorkbookFromSnapshot,
|
|
41
|
+
recomputeIfStale: formula.recomputeIfStale,
|
|
42
|
+
drawSpreadsheet: renderer.drawSpreadsheet,
|
|
43
|
+
LayoutEngine: layout.LayoutEngine,
|
|
44
|
+
rowColToRef: utils.rowColToRef,
|
|
45
|
+
refToRowCol: utils.refToRowCol,
|
|
46
|
+
parseExcelBuffer: excel.parseExcelBuffer,
|
|
47
|
+
parseCsvFile: csv.parseCsvFile,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function fetchArrayBuffer(url: string, signal: AbortSignal): Promise<ArrayBuffer> {
|
|
52
|
+
const response = await fetch(url, { signal });
|
|
53
|
+
if (!response.ok) throw new Error(`Spreadsheet fetch failed (${response.status})`);
|
|
54
|
+
return response.arrayBuffer();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface SpreadsheetViewProps {
|
|
58
|
+
file: DisplayFile;
|
|
59
|
+
labels: PreviewLabels;
|
|
60
|
+
onError?: (error: unknown, meta: { fileId: string; mimeType: string }) => void;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface ParseState {
|
|
64
|
+
loading: boolean;
|
|
65
|
+
workbook: WorkbookModel | undefined;
|
|
66
|
+
error: string | undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Read-only spreadsheet preview: parses Excel/CSV into a `WorkbookModel` and
|
|
71
|
+
* paints it with the `@lotics/xlsx` canvas renderer. Virtual-scroll — a
|
|
72
|
+
* transparent scroll layer (sized to the sheet) sits over a viewport-sized
|
|
73
|
+
* canvas that redraws on scroll. No editing, ribbon, or selection.
|
|
74
|
+
*/
|
|
75
|
+
export function SpreadsheetView({ file, labels, onError }: SpreadsheetViewProps) {
|
|
76
|
+
const apiRef = useRef<XlsxApi | null>(null);
|
|
77
|
+
const [state, setState] = useState<ParseState>({ loading: true, workbook: undefined, error: undefined });
|
|
78
|
+
const [sheetIndex, setSheetIndex] = useState(0);
|
|
79
|
+
|
|
80
|
+
const viewportRef = useRef<HTMLDivElement>(null);
|
|
81
|
+
const scrollRef = useRef<HTMLDivElement>(null);
|
|
82
|
+
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
83
|
+
const rafRef = useRef<number | null>(null);
|
|
84
|
+
const [viewport, setViewport] = useState({ w: 0, h: 0 });
|
|
85
|
+
const [scroll, setScroll] = useState({ x: 0, y: 0 });
|
|
86
|
+
|
|
87
|
+
// Parse the file into a workbook (loads the engine on first spreadsheet open).
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
const controller = new AbortController();
|
|
90
|
+
setState({ loading: true, workbook: undefined, error: undefined });
|
|
91
|
+
const run = async () => {
|
|
92
|
+
const api = apiRef.current ?? (apiRef.current = await loadXlsx());
|
|
93
|
+
const parsed = isExcelMimeType(file.mimeType)
|
|
94
|
+
? api.parseExcelBuffer(await fetchArrayBuffer(file.url, controller.signal))
|
|
95
|
+
: await api.parseCsvFile(file.url, controller.signal);
|
|
96
|
+
if (controller.signal.aborted) return;
|
|
97
|
+
const wb = api.loadWorkbookFromSnapshot(parsed);
|
|
98
|
+
api.recomputeIfStale(wb);
|
|
99
|
+
if (controller.signal.aborted) return;
|
|
100
|
+
setSheetIndex(wb.activeSheetIndex);
|
|
101
|
+
setState({ loading: false, workbook: wb, error: undefined });
|
|
102
|
+
};
|
|
103
|
+
run().catch((err: unknown) => {
|
|
104
|
+
if (controller.signal.aborted) return;
|
|
105
|
+
if (err instanceof DOMException && err.name === "AbortError") return;
|
|
106
|
+
const passwordProtected = err instanceof Error && err.name === "PasswordProtectedError";
|
|
107
|
+
onError?.(err, { fileId: file.id, mimeType: file.mimeType });
|
|
108
|
+
setState({
|
|
109
|
+
loading: false,
|
|
110
|
+
workbook: undefined,
|
|
111
|
+
error: passwordProtected ? labels.passwordProtected : labels.loadFailed,
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
return () => controller.abort();
|
|
115
|
+
}, [file.url, file.id, file.mimeType, labels.loadFailed, labels.passwordProtected, onError]);
|
|
116
|
+
|
|
117
|
+
// Track the viewport size.
|
|
118
|
+
useEffect(() => {
|
|
119
|
+
const el = viewportRef.current;
|
|
120
|
+
if (!el || typeof ResizeObserver === "undefined") return;
|
|
121
|
+
const update = () => setViewport({ w: el.clientWidth, h: el.clientHeight });
|
|
122
|
+
update();
|
|
123
|
+
const ro = new ResizeObserver(update);
|
|
124
|
+
ro.observe(el);
|
|
125
|
+
return () => ro.disconnect();
|
|
126
|
+
}, [state.workbook]);
|
|
127
|
+
|
|
128
|
+
// Coalesce native scroll (which fires faster than frames) into one redraw per frame.
|
|
129
|
+
const onScroll = useCallback(() => {
|
|
130
|
+
if (rafRef.current != null) return;
|
|
131
|
+
rafRef.current = requestAnimationFrame(() => {
|
|
132
|
+
rafRef.current = null;
|
|
133
|
+
const el = scrollRef.current;
|
|
134
|
+
if (el) setScroll({ x: el.scrollLeft, y: el.scrollTop });
|
|
135
|
+
});
|
|
136
|
+
}, []);
|
|
137
|
+
useEffect(
|
|
138
|
+
() => () => {
|
|
139
|
+
if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
|
|
140
|
+
},
|
|
141
|
+
[],
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
const layout = useMemo(() => {
|
|
145
|
+
const api = apiRef.current;
|
|
146
|
+
const wb = state.workbook;
|
|
147
|
+
if (!api || !wb) return null;
|
|
148
|
+
const s = wb.sheets[sheetIndex];
|
|
149
|
+
if (!s) return null;
|
|
150
|
+
const used = s.getUsedRange();
|
|
151
|
+
const config: LayoutConfig = {
|
|
152
|
+
colCount: Math.max(used.maxCol + 2, 26),
|
|
153
|
+
rowCount: Math.max(used.maxRow + 5, 50),
|
|
154
|
+
colWidths: s.colWidths,
|
|
155
|
+
rowHeights: s.rowHeights,
|
|
156
|
+
defaultColWidth: s.defaultColWidth,
|
|
157
|
+
defaultRowHeight: s.defaultRowHeight,
|
|
158
|
+
hiddenCols: s.hiddenCols,
|
|
159
|
+
hiddenRows: s.hiddenRows,
|
|
160
|
+
freeze: s.freeze,
|
|
161
|
+
};
|
|
162
|
+
return new api.LayoutEngine(config);
|
|
163
|
+
}, [state.workbook, sheetIndex]);
|
|
164
|
+
|
|
165
|
+
// Redraw the visible range whenever scroll / size / sheet changes.
|
|
166
|
+
useEffect(() => {
|
|
167
|
+
const api = apiRef.current;
|
|
168
|
+
const wb = state.workbook;
|
|
169
|
+
const canvas = canvasRef.current;
|
|
170
|
+
if (!api || !wb || !layout || !canvas || viewport.w === 0 || viewport.h === 0) return;
|
|
171
|
+
const s = wb.sheets[sheetIndex];
|
|
172
|
+
if (!s) return;
|
|
173
|
+
const dpr = window.devicePixelRatio || 1;
|
|
174
|
+
// Resize only when the viewport actually changes — assigning width/height
|
|
175
|
+
// reallocates (and clears) the backing store, so doing it per scroll frame
|
|
176
|
+
// is wasteful. On scroll the size is unchanged; drawSpreadsheet clears via
|
|
177
|
+
// its own full-viewport fillRect, so a plain redraw suffices.
|
|
178
|
+
const wPx = Math.round(viewport.w * dpr);
|
|
179
|
+
const hPx = Math.round(viewport.h * dpr);
|
|
180
|
+
if (canvas.width !== wPx) canvas.width = wPx;
|
|
181
|
+
if (canvas.height !== hPx) canvas.height = hPx;
|
|
182
|
+
const ctx = canvas.getContext("2d");
|
|
183
|
+
if (!ctx) return;
|
|
184
|
+
|
|
185
|
+
const mergedCells: MergedCellRange[] = s.mergedCells.map((ref) => {
|
|
186
|
+
const [a, b] = ref.split(":");
|
|
187
|
+
const start = api.refToRowCol(a ?? "");
|
|
188
|
+
const end = api.refToRowCol(b ?? a ?? "");
|
|
189
|
+
return {
|
|
190
|
+
startRow: start?.row ?? 1,
|
|
191
|
+
startCol: start?.col ?? 1,
|
|
192
|
+
endRow: end?.row ?? start?.row ?? 1,
|
|
193
|
+
endCol: end?.col ?? start?.col ?? 1,
|
|
194
|
+
};
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
const config: RenderConfig = {
|
|
198
|
+
layout,
|
|
199
|
+
scrollX: scroll.x,
|
|
200
|
+
scrollY: scroll.y,
|
|
201
|
+
viewWidth: viewport.w,
|
|
202
|
+
viewHeight: viewport.h,
|
|
203
|
+
devicePixelRatio: dpr,
|
|
204
|
+
getCell: (row, col) => {
|
|
205
|
+
const cell = s.getCell(api.rowColToRef(row, col));
|
|
206
|
+
if (!cell) return undefined;
|
|
207
|
+
return {
|
|
208
|
+
displayValue: cell.displayValue,
|
|
209
|
+
style: wb.styles.get(cell.styleIndex),
|
|
210
|
+
richText: cell.richText,
|
|
211
|
+
isHyperlink: s.hyperlinks.has(api.rowColToRef(row, col)) || undefined,
|
|
212
|
+
};
|
|
213
|
+
},
|
|
214
|
+
mergedCells,
|
|
215
|
+
freeze: s.freeze,
|
|
216
|
+
showGridLines: s.view.showGridLines,
|
|
217
|
+
images: s.images,
|
|
218
|
+
charts: s.charts,
|
|
219
|
+
drawings: s.drawings,
|
|
220
|
+
richText: (row, col) => s.getCell(api.rowColToRef(row, col))?.richText,
|
|
221
|
+
};
|
|
222
|
+
api.drawSpreadsheet(ctx, config);
|
|
223
|
+
}, [state.workbook, sheetIndex, layout, viewport, scroll]);
|
|
224
|
+
|
|
225
|
+
if (state.loading) {
|
|
226
|
+
return (
|
|
227
|
+
<View style={styles.center}>
|
|
228
|
+
<Text size="sm" color="muted">
|
|
229
|
+
…
|
|
230
|
+
</Text>
|
|
231
|
+
</View>
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
if (state.error !== undefined || !state.workbook) {
|
|
235
|
+
return (
|
|
236
|
+
<View style={styles.center}>
|
|
237
|
+
<Text size="sm" color="muted">
|
|
238
|
+
{state.error ?? labels.loadFailed}
|
|
239
|
+
</Text>
|
|
240
|
+
<View style={{ marginTop: 12 }}>
|
|
241
|
+
<Button
|
|
242
|
+
icon="download"
|
|
243
|
+
title={labels.download}
|
|
244
|
+
color="secondary"
|
|
245
|
+
onPress={() => void downloadFileFromUrl(file.url, file.filename)}
|
|
246
|
+
/>
|
|
247
|
+
</View>
|
|
248
|
+
</View>
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const sheets = state.workbook.sheets;
|
|
253
|
+
const totalWidth = layout?.totalWidth ?? 0;
|
|
254
|
+
const totalHeight = layout?.totalHeight ?? 0;
|
|
255
|
+
|
|
256
|
+
return (
|
|
257
|
+
<View style={styles.container}>
|
|
258
|
+
<div style={viewportStyle} ref={viewportRef}>
|
|
259
|
+
<canvas ref={canvasRef} style={{ ...canvasStyle, width: viewport.w, height: viewport.h }} />
|
|
260
|
+
<div ref={scrollRef} onScroll={onScroll} style={scrollLayerStyle}>
|
|
261
|
+
<div style={{ width: totalWidth, height: totalHeight }} />
|
|
262
|
+
</div>
|
|
263
|
+
</div>
|
|
264
|
+
{sheets.length > 1 ? (
|
|
265
|
+
<View style={styles.tabs}>
|
|
266
|
+
{sheets.map((s, i) => (
|
|
267
|
+
<Pressable
|
|
268
|
+
key={i}
|
|
269
|
+
accessibilityRole="tab"
|
|
270
|
+
accessibilityState={{ selected: i === sheetIndex }}
|
|
271
|
+
onPress={() => {
|
|
272
|
+
setSheetIndex(i);
|
|
273
|
+
setScroll({ x: 0, y: 0 });
|
|
274
|
+
if (scrollRef.current) scrollRef.current.scrollTo({ left: 0, top: 0 });
|
|
275
|
+
}}
|
|
276
|
+
style={[styles.tab, i === sheetIndex ? styles.tabActive : null]}
|
|
277
|
+
>
|
|
278
|
+
<Text size="xs" color={i === sheetIndex ? "default" : "muted"} numberOfLines={1}>
|
|
279
|
+
{s.name}
|
|
280
|
+
</Text>
|
|
281
|
+
</Pressable>
|
|
282
|
+
))}
|
|
283
|
+
</View>
|
|
284
|
+
) : null}
|
|
285
|
+
</View>
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const styles = StyleSheet.create({
|
|
290
|
+
container: { flex: 1 },
|
|
291
|
+
center: { flex: 1, justifyContent: "center", alignItems: "center", padding: 48 },
|
|
292
|
+
tabs: {
|
|
293
|
+
flexDirection: "row",
|
|
294
|
+
borderTopWidth: 1,
|
|
295
|
+
borderTopColor: colors.border,
|
|
296
|
+
backgroundColor: colors.zinc[50],
|
|
297
|
+
},
|
|
298
|
+
tab: { paddingHorizontal: 14, paddingVertical: 8, borderRightWidth: 1, borderRightColor: colors.border },
|
|
299
|
+
tabActive: { backgroundColor: colors.background },
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
const viewportStyle: React.CSSProperties = { position: "relative", flex: 1, overflow: "hidden" };
|
|
303
|
+
const canvasStyle: React.CSSProperties = { position: "absolute", top: 0, left: 0 };
|
|
304
|
+
const scrollLayerStyle: React.CSSProperties = { position: "absolute", inset: 0, overflow: "auto" };
|