@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/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
+ }
@@ -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
- !multi && isSelected ? (
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
  }
@@ -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 { MenuButton } from "./menu_button";
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
- /** Render a rich row from the option (use its `data` payload). Falls back to
34
- * `option.label`. */
35
- renderOptionContent?: (option: PickerOption<T, D>) => ReactNode;
36
- /** Marks the matching row as the current selection (check + highlight). The
37
- * box itself stays a search affordance the selection is shown by the host. */
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
- /** Show a spinner while results are in flight. */
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
- const OPTION_HEIGHT = 44;
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 input (the "panel") whose
60
- * typing drives a debounced, server-side search; results drop into a Popover
61
- * listbox below. Focused while empty, it offers `recentOptions`. Picking a row
62
- * fires `onValueChange` and clears the box for the next search — the selection
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
- renderOptionContent,
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 && searching ? (
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
- const isSelected = opt.value === selectedValue;
240
- return (
241
- <MenuButton
242
- key={opt.value}
243
- nativeID={optionId(i)}
244
- testID={`search-option-${opt.value}`}
245
- role="option"
246
- title={
247
- renderOptionContent ? (
248
- renderOptionContent(opt)
249
- ) : (
250
- <Text userSelect="none" numberOfLines={1}>
251
- {opt.label ?? opt.value}
252
- </Text>
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" };