@lotics/ui 1.15.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/mime.ts +46 -0
- package/src/picker_menu.tsx +29 -5
- package/src/spreadsheet_view.tsx +304 -0
- package/src/table.tsx +105 -36
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotics/ui",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.16.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./tokens": "./src/tokens.ts",
|
|
@@ -9,6 +9,11 @@
|
|
|
9
9
|
"./download": "./src/download.ts",
|
|
10
10
|
"./file_badge": "./src/file_badge.tsx",
|
|
11
11
|
"./file_thumbnail": "./src/file_thumbnail.tsx",
|
|
12
|
+
"./file_preview": {
|
|
13
|
+
"react-native": "./src/file_preview.tsx",
|
|
14
|
+
"default": "./src/file_preview.web.tsx"
|
|
15
|
+
},
|
|
16
|
+
"./file_preview_types": "./src/file_preview_types.ts",
|
|
12
17
|
"./file_gallery_modal": "./src/file_gallery_modal.tsx",
|
|
13
18
|
"./pagination": "./src/pagination.tsx",
|
|
14
19
|
"./bar_chart": "./src/bar_chart.tsx",
|
|
@@ -153,6 +158,8 @@
|
|
|
153
158
|
},
|
|
154
159
|
"license": "MIT",
|
|
155
160
|
"peerDependencies": {
|
|
161
|
+
"@lotics/docx": "^0.1.0",
|
|
162
|
+
"@lotics/xlsx": "^0.1.0",
|
|
156
163
|
"@react-native-picker/picker": ">=2.0.0",
|
|
157
164
|
"expo-image": ">=3.0.0",
|
|
158
165
|
"lucide-react": ">=0.460.0",
|
|
@@ -165,6 +172,12 @@
|
|
|
165
172
|
"recharts": ">=3.0.0"
|
|
166
173
|
},
|
|
167
174
|
"peerDependenciesMeta": {
|
|
175
|
+
"@lotics/docx": {
|
|
176
|
+
"optional": true
|
|
177
|
+
},
|
|
178
|
+
"@lotics/xlsx": {
|
|
179
|
+
"optional": true
|
|
180
|
+
},
|
|
168
181
|
"expo-image": {
|
|
169
182
|
"optional": true
|
|
170
183
|
},
|
|
@@ -187,6 +200,8 @@
|
|
|
187
200
|
"test": "vitest run"
|
|
188
201
|
},
|
|
189
202
|
"devDependencies": {
|
|
203
|
+
"@lotics/docx": "^0.1.0",
|
|
204
|
+
"@lotics/xlsx": "^0.1.0",
|
|
190
205
|
"recharts": "^3.8.1"
|
|
191
206
|
}
|
|
192
207
|
}
|
package/src/combobox.tsx
CHANGED
|
@@ -29,6 +29,13 @@ export interface ComboboxProps<T extends string = string> {
|
|
|
29
29
|
searchPlaceholder?: string;
|
|
30
30
|
/** Shown when there are no results and not loading. Default: "No results". */
|
|
31
31
|
emptyText?: string;
|
|
32
|
+
/** Accept free entry: when the typed query matches no option, offer it as a
|
|
33
|
+
* custom value. `onValueChange` then receives `{ value: query, label: query }`.
|
|
34
|
+
* The consumer decides what an unknown value means (e.g. a manually-typed id). */
|
|
35
|
+
allowCustom?: boolean;
|
|
36
|
+
/** Label for the free-entry row (default: the raw query). Return `null` to
|
|
37
|
+
* suppress it for a given query (e.g. still incomplete/invalid). */
|
|
38
|
+
customOptionLabel?: (query: string) => string | null;
|
|
32
39
|
disabled?: boolean;
|
|
33
40
|
autoFocus?: boolean;
|
|
34
41
|
testID?: string;
|
|
@@ -56,6 +63,8 @@ export function Combobox<T extends string>(props: ComboboxProps<T>) {
|
|
|
56
63
|
placeholder,
|
|
57
64
|
searchPlaceholder,
|
|
58
65
|
emptyText,
|
|
66
|
+
allowCustom,
|
|
67
|
+
customOptionLabel,
|
|
59
68
|
disabled = false,
|
|
60
69
|
autoFocus = false,
|
|
61
70
|
testID,
|
|
@@ -73,9 +82,10 @@ export function Combobox<T extends string>(props: ComboboxProps<T>) {
|
|
|
73
82
|
(v: T) => {
|
|
74
83
|
const opt = options.find((o) => o.value === v);
|
|
75
84
|
if (opt) onValueChange(opt);
|
|
85
|
+
else if (allowCustom && v) onValueChange({ value: v, label: v });
|
|
76
86
|
setOpen(false);
|
|
77
87
|
},
|
|
78
|
-
[options, onValueChange],
|
|
88
|
+
[options, onValueChange, allowCustom],
|
|
79
89
|
);
|
|
80
90
|
|
|
81
91
|
return (
|
|
@@ -117,6 +127,8 @@ export function Combobox<T extends string>(props: ComboboxProps<T>) {
|
|
|
117
127
|
onValueChange={handleSelect}
|
|
118
128
|
onRequestClose={() => setOpen(false)}
|
|
119
129
|
enableSearch
|
|
130
|
+
allowCustom={allowCustom}
|
|
131
|
+
customOptionLabel={customOptionLabel}
|
|
120
132
|
onSearchChange={debouncedSearch}
|
|
121
133
|
// Local filtering is wrong when the consumer drives search server-side.
|
|
122
134
|
serverFiltered={onSearchChange !== undefined}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { customOptionFor } from "./custom_option";
|
|
3
|
+
|
|
4
|
+
const opts = [
|
|
5
|
+
{ value: "BX1N", label: "BX1N" },
|
|
6
|
+
{ value: "UX1N", label: "UX1N" },
|
|
7
|
+
];
|
|
8
|
+
|
|
9
|
+
describe("customOptionFor", () => {
|
|
10
|
+
it("offers the trimmed query when nothing matches", () => {
|
|
11
|
+
expect(customOptionFor({ allowCustom: true, multi: false, query: " ABCD ", options: opts }))
|
|
12
|
+
.toEqual({ value: "ABCD", label: "ABCD" });
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("suppressed when free entry is off", () => {
|
|
16
|
+
expect(customOptionFor({ allowCustom: false, multi: false, query: "ABCD", options: opts })).toBeNull();
|
|
17
|
+
expect(customOptionFor({ allowCustom: undefined, multi: false, query: "ABCD", options: opts })).toBeNull();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("suppressed for multi-select", () => {
|
|
21
|
+
expect(customOptionFor({ allowCustom: true, multi: true, query: "ABCD", options: opts })).toBeNull();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("suppressed for an empty / whitespace query", () => {
|
|
25
|
+
expect(customOptionFor({ allowCustom: true, multi: false, query: " ", options: opts })).toBeNull();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("suppressed when the query matches an option value (case-insensitive)", () => {
|
|
29
|
+
expect(customOptionFor({ allowCustom: true, multi: false, query: "bx1n", options: opts })).toBeNull();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("uses customOptionLabel for the row label but keeps the raw value", () => {
|
|
33
|
+
expect(customOptionFor({
|
|
34
|
+
allowCustom: true, multi: false, query: "ABCD", options: opts,
|
|
35
|
+
customOptionLabel: (q) => `Add "${q}"`,
|
|
36
|
+
})).toEqual({ value: "ABCD", label: 'Add "ABCD"' });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("suppressed when customOptionLabel returns null", () => {
|
|
40
|
+
expect(customOptionFor({
|
|
41
|
+
allowCustom: true, multi: false, query: "AB", options: opts,
|
|
42
|
+
customOptionLabel: () => null,
|
|
43
|
+
})).toBeNull();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("ignores undefined / false holes in the options list", () => {
|
|
47
|
+
expect(customOptionFor({ allowCustom: true, multi: false, query: "ABCD", options: [undefined, false, ...opts] }))
|
|
48
|
+
.toEqual({ value: "ABCD", label: "ABCD" });
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { PickerOption } from "./picker";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Resolve the free-entry ("custom") option a search picker should offer for the
|
|
5
|
+
* current query — or `null` when it should not be offered. The option's `value`
|
|
6
|
+
* stays the raw trimmed query; consumers decide what an unknown value means.
|
|
7
|
+
*
|
|
8
|
+
* Suppressed when: free entry is disabled, the picker is multi-select, the query
|
|
9
|
+
* is empty, the query already matches an existing option's value
|
|
10
|
+
* (case-insensitive), or `customOptionLabel` returns `null` for it.
|
|
11
|
+
*
|
|
12
|
+
* Pure and RN-free so it is unit-testable in isolation from `PickerMenu`.
|
|
13
|
+
*/
|
|
14
|
+
export function customOptionFor<T extends string>(args: {
|
|
15
|
+
allowCustom: boolean | undefined;
|
|
16
|
+
multi: boolean;
|
|
17
|
+
query: string;
|
|
18
|
+
options: (PickerOption<T> | undefined | false)[];
|
|
19
|
+
customOptionLabel?: (query: string) => string | null;
|
|
20
|
+
}): PickerOption<T> | null {
|
|
21
|
+
const { allowCustom, multi, query, options, customOptionLabel } = args;
|
|
22
|
+
if (!allowCustom || multi) return null;
|
|
23
|
+
const q = query.trim();
|
|
24
|
+
if (!q || options.some((o) => o && String(o.value).toLowerCase() === q.toLowerCase())) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
const label = customOptionLabel ? customOptionLabel(q) : q;
|
|
28
|
+
if (label == null) return null;
|
|
29
|
+
return { value: q as T, label };
|
|
30
|
+
}
|
|
@@ -1,12 +1,11 @@
|
|
|
1
|
-
// FileGalleryModal —
|
|
2
|
-
// Pure primitive: takes DisplayFile[] only, no Lotics domain coupling.
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
1
|
+
// FileGalleryModal — fullscreen file preview with prev/next nav + ESC close.
|
|
2
|
+
// Pure primitive: takes DisplayFile[] only, no Lotics domain coupling. Renders
|
|
3
|
+
// every previewable type (image/PDF/video/audio/Word/Excel/CSV) via FilePreview,
|
|
4
|
+
// whose document engines (@lotics/docx, @lotics/xlsx) are optional peer deps
|
|
5
|
+
// loaded lazily — so a consumer that only shows images pays nothing extra.
|
|
6
6
|
|
|
7
7
|
import { useCallback, useEffect, useRef } from "react";
|
|
8
8
|
import {
|
|
9
|
-
Image,
|
|
10
9
|
Modal,
|
|
11
10
|
Pressable,
|
|
12
11
|
StyleSheet,
|
|
@@ -15,8 +14,9 @@ import {
|
|
|
15
14
|
import { Text } from "./text";
|
|
16
15
|
import { Icon } from "./icon";
|
|
17
16
|
import { colors } from "./colors";
|
|
18
|
-
import { isImageMimeType } from "./mime";
|
|
19
17
|
import type { DisplayFile } from "./file_thumbnail";
|
|
18
|
+
import { FilePreview } from "./file_preview";
|
|
19
|
+
import type { PreviewLabels } from "./file_preview_types";
|
|
20
20
|
|
|
21
21
|
export interface FileGalleryModalProps {
|
|
22
22
|
files: DisplayFile[];
|
|
@@ -24,19 +24,22 @@ export interface FileGalleryModalProps {
|
|
|
24
24
|
activeIndex: number | null;
|
|
25
25
|
onIndexChange: (next: number | null) => void;
|
|
26
26
|
/**
|
|
27
|
-
* Optional caption suffix shown under the
|
|
27
|
+
* Optional caption suffix shown under the file. Useful for hint text like
|
|
28
28
|
* "ESC để đóng · ←/→ để chuyển". When omitted, just shows filename + count.
|
|
29
29
|
*/
|
|
30
30
|
captionHint?: string;
|
|
31
|
+
/** Translated preview strings; English fallback when omitted. */
|
|
32
|
+
labels?: Partial<PreviewLabels>;
|
|
33
|
+
/** Reported when a file fails to render (host wires to its logger). */
|
|
34
|
+
onError?: (error: unknown, meta: { fileId: string; mimeType: string }) => void;
|
|
31
35
|
}
|
|
32
36
|
|
|
33
37
|
/**
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
* a Lotics-specific gallery instead.
|
|
38
|
+
* Fullscreen file preview gallery. Renders any previewable MIME type inline
|
|
39
|
+
* via FilePreview; unknown types show a download placeholder.
|
|
37
40
|
*/
|
|
38
41
|
export function FileGalleryModal(props: FileGalleryModalProps) {
|
|
39
|
-
const { files, activeIndex, onIndexChange, captionHint } = props;
|
|
42
|
+
const { files, activeIndex, onIndexChange, captionHint, labels, onError } = props;
|
|
40
43
|
const visible = activeIndex !== null;
|
|
41
44
|
const overlayRef = useRef<View | null>(null);
|
|
42
45
|
|
|
@@ -81,22 +84,9 @@ export function FileGalleryModal(props: FileGalleryModalProps) {
|
|
|
81
84
|
style={StyleSheet.absoluteFill}
|
|
82
85
|
/>
|
|
83
86
|
|
|
84
|
-
{
|
|
85
|
-
<
|
|
86
|
-
|
|
87
|
-
style={styles.image}
|
|
88
|
-
resizeMode="contain"
|
|
89
|
-
/>
|
|
90
|
-
) : (
|
|
91
|
-
<View style={styles.unsupportedCard}>
|
|
92
|
-
<Text size="md" style={styles.unsupportedText}>
|
|
93
|
-
{file.filename}
|
|
94
|
-
</Text>
|
|
95
|
-
<Text size="sm" color="muted">
|
|
96
|
-
Cannot preview this file type in the gallery.
|
|
97
|
-
</Text>
|
|
98
|
-
</View>
|
|
99
|
-
)}
|
|
87
|
+
<View style={styles.previewPanel}>
|
|
88
|
+
<FilePreview file={file} labels={labels} onError={onError} />
|
|
89
|
+
</View>
|
|
100
90
|
|
|
101
91
|
{activeIndex > 0 ? (
|
|
102
92
|
<Pressable
|
|
@@ -137,20 +127,12 @@ const styles = StyleSheet.create({
|
|
|
137
127
|
justifyContent: "center",
|
|
138
128
|
alignItems: "center",
|
|
139
129
|
},
|
|
140
|
-
|
|
130
|
+
previewPanel: {
|
|
141
131
|
width: "92%",
|
|
142
|
-
height: "
|
|
143
|
-
|
|
144
|
-
unsupportedCard: {
|
|
145
|
-
backgroundColor: colors.white,
|
|
132
|
+
height: "86%",
|
|
133
|
+
backgroundColor: colors.background,
|
|
146
134
|
borderRadius: 8,
|
|
147
|
-
|
|
148
|
-
maxWidth: 480,
|
|
149
|
-
gap: 8,
|
|
150
|
-
alignItems: "center",
|
|
151
|
-
},
|
|
152
|
-
unsupportedText: {
|
|
153
|
-
fontWeight: "600",
|
|
135
|
+
overflow: "hidden",
|
|
154
136
|
},
|
|
155
137
|
navButton: {
|
|
156
138
|
position: "absolute",
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { View, Image, StyleSheet } from "react-native";
|
|
2
|
+
import { Text } from "./text";
|
|
3
|
+
import { DocumentCard } from "./file_thumbnail";
|
|
4
|
+
import { isImageMimeType } from "./mime";
|
|
5
|
+
import { type FilePreviewProps, resolveLabels } from "./file_preview_types";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Native (mobile) fallback: images render full-bleed; everything else shows a
|
|
9
|
+
* document placeholder. Rich document rendering (docx/xlsx/pdf) is web-only —
|
|
10
|
+
* the `.web.tsx` variant carries it; native consumers download instead.
|
|
11
|
+
*/
|
|
12
|
+
export function FilePreview({ file, labels }: FilePreviewProps) {
|
|
13
|
+
const l = resolveLabels(labels);
|
|
14
|
+
if (isImageMimeType(file.mimeType)) {
|
|
15
|
+
return (
|
|
16
|
+
<Image
|
|
17
|
+
source={{ uri: file.url }}
|
|
18
|
+
resizeMode="contain"
|
|
19
|
+
style={styles.image}
|
|
20
|
+
accessibilityIgnoresInvertColors
|
|
21
|
+
/>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
return (
|
|
25
|
+
<View style={styles.placeholder}>
|
|
26
|
+
<DocumentCard mimeType={file.mimeType} filename={file.filename} size={120} />
|
|
27
|
+
<Text size="sm" color="muted" style={styles.placeholderText}>
|
|
28
|
+
{l.notAvailable}
|
|
29
|
+
</Text>
|
|
30
|
+
</View>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const styles = StyleSheet.create({
|
|
35
|
+
image: { flex: 1, width: "100%" },
|
|
36
|
+
placeholder: { flex: 1, justifyContent: "center", alignItems: "center", gap: 16 },
|
|
37
|
+
placeholderText: { marginTop: 8 },
|
|
38
|
+
});
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { View, StyleSheet } from "react-native";
|
|
3
|
+
import { Text } from "./text";
|
|
4
|
+
import { Button } from "./button";
|
|
5
|
+
import { DocumentCard, MediaCard } from "./file_thumbnail";
|
|
6
|
+
import {
|
|
7
|
+
isImageMimeType,
|
|
8
|
+
isPdfMimeType,
|
|
9
|
+
isDocxMimeType,
|
|
10
|
+
isVideoMimeType,
|
|
11
|
+
isAudioMimeType,
|
|
12
|
+
isPreviewableMimeType,
|
|
13
|
+
} from "./mime";
|
|
14
|
+
import { type FilePreviewProps, resolveLabels, type PreviewLabels } from "./file_preview_types";
|
|
15
|
+
import { SpreadsheetView } from "./spreadsheet_view";
|
|
16
|
+
import { downloadFileFromUrl } from "./download";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Universal inline file preview (web). Renders by MIME type: images, PDF, video
|
|
20
|
+
* and audio use browser-native elements; Word via the lazy-loaded `@lotics/docx`
|
|
21
|
+
* renderer; Excel/CSV via the read-only `@lotics/xlsx` canvas. Heavy engines are
|
|
22
|
+
* dynamic-imported so non-document files never pay their bundle cost.
|
|
23
|
+
*
|
|
24
|
+
* Pure: i18n via `labels` props, errors via `onError` — no lingui/analytics
|
|
25
|
+
* (purity test). Lifted from the frontend gallery so both share one renderer.
|
|
26
|
+
*/
|
|
27
|
+
export function FilePreview({ file, labels, onError }: FilePreviewProps) {
|
|
28
|
+
const l = resolveLabels(labels);
|
|
29
|
+
|
|
30
|
+
if (isImageMimeType(file.mimeType)) {
|
|
31
|
+
return <img alt={file.filename} src={file.url} style={imageStyle} />;
|
|
32
|
+
}
|
|
33
|
+
if (isPdfMimeType(file.mimeType)) {
|
|
34
|
+
return <iframe src={file.url} title={file.filename} style={fullFrameStyle} />;
|
|
35
|
+
}
|
|
36
|
+
if (isDocxMimeType(file.mimeType)) {
|
|
37
|
+
return <WordPreview file={file} labels={l} onError={onError} />;
|
|
38
|
+
}
|
|
39
|
+
if (isVideoMimeType(file.mimeType)) {
|
|
40
|
+
return <video controls preload="metadata" src={file.url} style={mediaElementStyle} />;
|
|
41
|
+
}
|
|
42
|
+
if (isAudioMimeType(file.mimeType)) {
|
|
43
|
+
return (
|
|
44
|
+
<div style={audioContainerStyle}>
|
|
45
|
+
<MediaCard mimeType={file.mimeType} filename={file.filename} icon="music" size={160} />
|
|
46
|
+
<audio controls preload="metadata" src={file.url} style={audioElementStyle} />
|
|
47
|
+
</div>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
if (isPreviewableMimeType(file.mimeType)) {
|
|
51
|
+
return <SpreadsheetView file={file} labels={l} onError={onError} />;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<View style={styles.placeholder}>
|
|
56
|
+
<DocumentCard mimeType={file.mimeType} filename={file.filename} size={120} />
|
|
57
|
+
<Text size="sm" color="muted" style={styles.placeholderText}>
|
|
58
|
+
{l.notAvailable}
|
|
59
|
+
</Text>
|
|
60
|
+
</View>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Lazy bridge — pulls jszip + prosemirror only when a .docx is actually opened. */
|
|
65
|
+
async function lazyLoadDocx(
|
|
66
|
+
...args: Parameters<typeof import("@lotics/docx/load").loadDocxIntoElement>
|
|
67
|
+
): ReturnType<typeof import("@lotics/docx/load").loadDocxIntoElement> {
|
|
68
|
+
const mod = await import("@lotics/docx/load");
|
|
69
|
+
return mod.loadDocxIntoElement(...args);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function WordPreview({
|
|
73
|
+
file,
|
|
74
|
+
labels,
|
|
75
|
+
onError,
|
|
76
|
+
}: {
|
|
77
|
+
file: FilePreviewProps["file"];
|
|
78
|
+
labels: PreviewLabels;
|
|
79
|
+
onError: FilePreviewProps["onError"];
|
|
80
|
+
}) {
|
|
81
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
82
|
+
const [state, setState] = useState<{ loading: boolean; error: string | undefined }>({
|
|
83
|
+
loading: true,
|
|
84
|
+
error: undefined,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
const controller = new AbortController();
|
|
89
|
+
let loadedView: { destroy: () => void } | null = null;
|
|
90
|
+
setState({ loading: true, error: undefined });
|
|
91
|
+
|
|
92
|
+
const run = async () => {
|
|
93
|
+
const response = await fetch(file.url, { signal: controller.signal });
|
|
94
|
+
if (!response.ok) throw new Error(`Document fetch failed (${response.status})`);
|
|
95
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
96
|
+
const bytes = new Uint8Array(arrayBuffer);
|
|
97
|
+
if (controller.signal.aborted || !containerRef.current) return;
|
|
98
|
+
containerRef.current.innerHTML = "";
|
|
99
|
+
const loaded = await lazyLoadDocx(containerRef.current, bytes);
|
|
100
|
+
if (controller.signal.aborted) {
|
|
101
|
+
loaded.view.destroy();
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
loadedView = loaded.view;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
run().then(
|
|
108
|
+
() => {
|
|
109
|
+
if (!controller.signal.aborted) setState({ loading: false, error: undefined });
|
|
110
|
+
},
|
|
111
|
+
(err: unknown) => {
|
|
112
|
+
if (controller.signal.aborted) return;
|
|
113
|
+
if (err instanceof DOMException && err.name === "AbortError") return;
|
|
114
|
+
onError?.(err, { fileId: file.id, mimeType: file.mimeType });
|
|
115
|
+
setState({ loading: false, error: labels.loadFailed });
|
|
116
|
+
},
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
return () => {
|
|
120
|
+
controller.abort();
|
|
121
|
+
loadedView?.destroy();
|
|
122
|
+
};
|
|
123
|
+
}, [file.url, file.id, file.mimeType, labels.loadFailed, onError]);
|
|
124
|
+
|
|
125
|
+
return (
|
|
126
|
+
<div style={wordContainerStyle}>
|
|
127
|
+
{state.loading && (
|
|
128
|
+
<div style={centerStyle}>
|
|
129
|
+
<Text size="sm" color="muted">
|
|
130
|
+
…
|
|
131
|
+
</Text>
|
|
132
|
+
</div>
|
|
133
|
+
)}
|
|
134
|
+
{state.error !== undefined && (
|
|
135
|
+
<div style={centerStyle}>
|
|
136
|
+
<Text size="sm" color="muted">
|
|
137
|
+
{state.error}
|
|
138
|
+
</Text>
|
|
139
|
+
<View style={{ marginTop: 12 }}>
|
|
140
|
+
<Button
|
|
141
|
+
icon="download"
|
|
142
|
+
title={labels.download}
|
|
143
|
+
color="secondary"
|
|
144
|
+
onPress={() => void downloadFileFromUrl(file.url, file.filename)}
|
|
145
|
+
/>
|
|
146
|
+
</View>
|
|
147
|
+
</div>
|
|
148
|
+
)}
|
|
149
|
+
<div ref={containerRef} style={state.loading ? hiddenStyle : wordContentStyle} />
|
|
150
|
+
</div>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const styles = StyleSheet.create({
|
|
155
|
+
placeholder: { flex: 1, justifyContent: "center", alignItems: "center", gap: 16 },
|
|
156
|
+
placeholderText: { marginTop: 8 },
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
const imageStyle: React.CSSProperties = {
|
|
160
|
+
width: "100%",
|
|
161
|
+
height: "100%",
|
|
162
|
+
objectFit: "contain",
|
|
163
|
+
backgroundColor: "rgba(10, 10, 10, 0.8)",
|
|
164
|
+
};
|
|
165
|
+
const fullFrameStyle: React.CSSProperties = { width: "100%", height: "100%", border: "none" };
|
|
166
|
+
const mediaElementStyle: React.CSSProperties = {
|
|
167
|
+
width: "100%",
|
|
168
|
+
height: "100%",
|
|
169
|
+
backgroundColor: "rgba(10, 10, 10, 0.8)",
|
|
170
|
+
objectFit: "contain",
|
|
171
|
+
};
|
|
172
|
+
const audioContainerStyle: React.CSSProperties = {
|
|
173
|
+
display: "flex",
|
|
174
|
+
flexDirection: "column",
|
|
175
|
+
alignItems: "center",
|
|
176
|
+
justifyContent: "center",
|
|
177
|
+
flex: 1,
|
|
178
|
+
gap: 24,
|
|
179
|
+
padding: 48,
|
|
180
|
+
};
|
|
181
|
+
const audioElementStyle: React.CSSProperties = { width: "min(480px, 100%)" };
|
|
182
|
+
const wordContainerStyle: React.CSSProperties = {
|
|
183
|
+
display: "flex",
|
|
184
|
+
flexDirection: "column",
|
|
185
|
+
flex: 1,
|
|
186
|
+
overflow: "auto",
|
|
187
|
+
backgroundColor: "#f4f4f5",
|
|
188
|
+
};
|
|
189
|
+
const wordContentStyle: React.CSSProperties = { display: "block", minHeight: "100%" };
|
|
190
|
+
const hiddenStyle: React.CSSProperties = { display: "none" };
|
|
191
|
+
const centerStyle: React.CSSProperties = {
|
|
192
|
+
display: "flex",
|
|
193
|
+
flexDirection: "column",
|
|
194
|
+
alignItems: "center",
|
|
195
|
+
justifyContent: "center",
|
|
196
|
+
flex: 1,
|
|
197
|
+
padding: 48,
|
|
198
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { DisplayFile } from "./file_thumbnail";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Caption/error strings shown inside the preview. `@lotics/ui` is i18n-free
|
|
5
|
+
* (purity test forbids lingui), so the host passes translated strings; English
|
|
6
|
+
* is the fallback.
|
|
7
|
+
*/
|
|
8
|
+
export interface PreviewLabels {
|
|
9
|
+
notAvailable: string;
|
|
10
|
+
loadFailed: string;
|
|
11
|
+
download: string;
|
|
12
|
+
passwordProtected: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const defaultPreviewLabels: PreviewLabels = {
|
|
16
|
+
notAvailable: "Preview not available",
|
|
17
|
+
loadFailed: "Failed to load preview",
|
|
18
|
+
download: "Download file",
|
|
19
|
+
passwordProtected: "This file is password-protected and cannot be previewed",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export interface FilePreviewProps {
|
|
23
|
+
file: DisplayFile;
|
|
24
|
+
/** Override any subset of the English default labels. */
|
|
25
|
+
labels?: Partial<PreviewLabels>;
|
|
26
|
+
/**
|
|
27
|
+
* Reported on a render/parse failure. Logging/analytics live in the host (the
|
|
28
|
+
* package stays analytics-free), so the host wires this to its logger.
|
|
29
|
+
*/
|
|
30
|
+
onError?: (error: unknown, meta: { fileId: string; mimeType: string }) => void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function resolveLabels(labels: Partial<PreviewLabels> | undefined): PreviewLabels {
|
|
34
|
+
return labels ? { ...defaultPreviewLabels, ...labels } : defaultPreviewLabels;
|
|
35
|
+
}
|
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
|
}
|
|
@@ -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" };
|
package/src/table.tsx
CHANGED
|
@@ -1,19 +1,40 @@
|
|
|
1
|
-
import { ScrollView, View, ViewStyle, StyleSheet } from "react-native";
|
|
1
|
+
import { Pressable, ScrollView, View, ViewStyle, StyleSheet } from "react-native";
|
|
2
2
|
import { Text } from "@lotics/ui/text";
|
|
3
3
|
import { ReactNode } from "react";
|
|
4
4
|
import { colors } from "@lotics/ui/colors";
|
|
5
5
|
|
|
6
|
+
export type SortDir = "asc" | "desc";
|
|
7
|
+
export interface TableSort<TRow extends Record<string, unknown>> {
|
|
8
|
+
key: keyof TRow;
|
|
9
|
+
dir: SortDir;
|
|
10
|
+
}
|
|
11
|
+
|
|
6
12
|
interface TableProps<TRow extends Record<string, unknown>> {
|
|
7
13
|
columns: Column<TRow>[];
|
|
8
14
|
rows: (TRow | null | false)[];
|
|
9
15
|
rowKey?: keyof TRow;
|
|
10
16
|
rowStyle?: (row: TRow) => ViewStyle | undefined;
|
|
17
|
+
/** Current sort. Pair with `onSortChange` to render sortable headers. The
|
|
18
|
+
* parent owns the actual sorting of `rows` — this only drives the indicator. */
|
|
19
|
+
sort?: TableSort<TRow> | null;
|
|
20
|
+
/** Pressing a `sortable` header calls this with the column key (the parent
|
|
21
|
+
* toggles asc/desc and re-sorts `rows`). */
|
|
22
|
+
onSortChange?: (key: keyof TRow) => void;
|
|
23
|
+
/** Pressing a row calls this — e.g. to open a detail drilldown. The row
|
|
24
|
+
* becomes a Pressable with a hover/press surface. Interactive cells nested
|
|
25
|
+
* inside (Pressables/buttons) capture their own press and don't bubble. */
|
|
26
|
+
onRowPress?: (row: TRow) => void;
|
|
11
27
|
}
|
|
12
28
|
|
|
13
29
|
interface Column<TRow extends Record<string, unknown>> {
|
|
14
30
|
key: keyof TRow;
|
|
15
31
|
label: string;
|
|
16
32
|
width?: number;
|
|
33
|
+
/** Horizontal alignment of header + cell content. Default "left". */
|
|
34
|
+
align?: "left" | "right";
|
|
35
|
+
/** When true (and `onSortChange` is set), the header is pressable + shows a
|
|
36
|
+
* sort arrow when this column is the active `sort`. */
|
|
37
|
+
sortable?: boolean;
|
|
17
38
|
renderCell?: RenderCell<TRow>;
|
|
18
39
|
}
|
|
19
40
|
|
|
@@ -25,48 +46,80 @@ type RenderCell<TRow extends Record<string, unknown>> = (params: {
|
|
|
25
46
|
const stickyHeader = [0];
|
|
26
47
|
|
|
27
48
|
export function Table<TRow extends Record<string, unknown>>(props: TableProps<TRow>) {
|
|
28
|
-
const { columns, rows, rowKey, rowStyle } = props;
|
|
49
|
+
const { columns, rows, rowKey, rowStyle, sort, onSortChange, onRowPress } = props;
|
|
29
50
|
|
|
30
51
|
return (
|
|
31
52
|
<ScrollView style={styles.container} stickyHeaderIndices={stickyHeader}>
|
|
32
53
|
<View style={styles.headerRow}>
|
|
33
|
-
{columns.map((column) =>
|
|
54
|
+
{columns.map((column) => {
|
|
55
|
+
const sortable = column.sortable && !!onSortChange;
|
|
56
|
+
const active = sort?.key === column.key;
|
|
57
|
+
const cellStyle = [
|
|
58
|
+
column.width ? { width: column.width } : styles.flexColumn,
|
|
59
|
+
styles.headerCell,
|
|
60
|
+
column.align === "right" ? styles.alignEnd : null,
|
|
61
|
+
];
|
|
62
|
+
const inner = (
|
|
63
|
+
<View style={styles.headerInner}>
|
|
64
|
+
<Text weight="medium" numberOfLines={1} userSelect="none">
|
|
65
|
+
{column.label}
|
|
66
|
+
</Text>
|
|
67
|
+
{sortable ? (
|
|
68
|
+
<Text size="xs" color="muted" userSelect="none">
|
|
69
|
+
{active ? (sort?.dir === "asc" ? "↑" : "↓") : "↕"}
|
|
70
|
+
</Text>
|
|
71
|
+
) : null}
|
|
72
|
+
</View>
|
|
73
|
+
);
|
|
74
|
+
return sortable ? (
|
|
75
|
+
<Pressable
|
|
76
|
+
key={column.key as string}
|
|
77
|
+
accessibilityRole="button"
|
|
78
|
+
accessibilityLabel={`Sắp xếp theo ${column.label}`}
|
|
79
|
+
onPress={() => onSortChange?.(column.key)}
|
|
80
|
+
style={cellStyle}
|
|
81
|
+
>
|
|
82
|
+
{inner}
|
|
83
|
+
</Pressable>
|
|
84
|
+
) : (
|
|
85
|
+
<View key={column.key as string} style={cellStyle}>
|
|
86
|
+
{inner}
|
|
87
|
+
</View>
|
|
88
|
+
);
|
|
89
|
+
})}
|
|
90
|
+
</View>
|
|
91
|
+
{rows.filter(Boolean).map((r, i) => {
|
|
92
|
+
const row = r as TRow;
|
|
93
|
+
const extraStyle = rowStyle?.(row);
|
|
94
|
+
const cells = columns.map((column) => (
|
|
34
95
|
<View
|
|
96
|
+
key={column.key as string}
|
|
35
97
|
style={[
|
|
36
98
|
column.width ? { width: column.width } : styles.flexColumn,
|
|
37
|
-
styles.
|
|
99
|
+
styles.bodyCell,
|
|
100
|
+
column.align === "right" ? styles.alignEnd : null,
|
|
38
101
|
]}
|
|
39
|
-
key={column.key as string}
|
|
40
102
|
>
|
|
41
|
-
|
|
42
|
-
{column
|
|
43
|
-
|
|
103
|
+
{column.renderCell ? (
|
|
104
|
+
column.renderCell({ row, column })
|
|
105
|
+
) : (
|
|
106
|
+
<Text numberOfLines={1}>{row[column.key]?.toString()}</Text>
|
|
107
|
+
)}
|
|
44
108
|
</View>
|
|
45
|
-
))
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
style={[styles.bodyRow, extraStyle]}
|
|
109
|
+
));
|
|
110
|
+
const key = rowKey ? String(row[rowKey]) : i;
|
|
111
|
+
return onRowPress ? (
|
|
112
|
+
<Pressable
|
|
113
|
+
key={key}
|
|
114
|
+
accessibilityRole="button"
|
|
115
|
+
onPress={() => onRowPress(row)}
|
|
116
|
+
style={({ pressed }) => [styles.bodyRow, pressed ? styles.rowPressed : null, extraStyle]}
|
|
54
117
|
>
|
|
55
|
-
{
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
styles.bodyCell,
|
|
61
|
-
]}
|
|
62
|
-
>
|
|
63
|
-
{column.renderCell ? (
|
|
64
|
-
column.renderCell({ row, column })
|
|
65
|
-
) : (
|
|
66
|
-
<Text numberOfLines={1}>{row[column.key]?.toString()}</Text>
|
|
67
|
-
)}
|
|
68
|
-
</View>
|
|
69
|
-
))}
|
|
118
|
+
{cells}
|
|
119
|
+
</Pressable>
|
|
120
|
+
) : (
|
|
121
|
+
<View key={key} style={[styles.bodyRow, extraStyle]}>
|
|
122
|
+
{cells}
|
|
70
123
|
</View>
|
|
71
124
|
);
|
|
72
125
|
})}
|
|
@@ -86,19 +139,35 @@ const styles = StyleSheet.create({
|
|
|
86
139
|
backgroundColor: colors.zinc[50],
|
|
87
140
|
},
|
|
88
141
|
headerCell: {
|
|
89
|
-
|
|
142
|
+
minHeight: 40,
|
|
90
143
|
justifyContent: "center",
|
|
91
|
-
paddingHorizontal:
|
|
144
|
+
paddingHorizontal: 12,
|
|
145
|
+
paddingVertical: 10,
|
|
92
146
|
},
|
|
93
|
-
|
|
147
|
+
headerInner: {
|
|
148
|
+
flexDirection: "row",
|
|
149
|
+
alignItems: "center",
|
|
150
|
+
gap: 4,
|
|
151
|
+
},
|
|
152
|
+
bodyRow: {
|
|
94
153
|
flexDirection: "row",
|
|
95
154
|
borderBottomWidth: 1,
|
|
96
155
|
borderBottomColor: colors.border,
|
|
156
|
+
alignItems: "stretch",
|
|
157
|
+
},
|
|
158
|
+
rowPressed: {
|
|
159
|
+
backgroundColor: colors.zinc[50],
|
|
97
160
|
},
|
|
161
|
+
// No fixed height — the row sizes to its content + vertical padding, so rich
|
|
162
|
+
// cells (avatar chips, buttons, stacked badges) get room instead of squeezing.
|
|
98
163
|
bodyCell: {
|
|
99
164
|
minHeight: 44,
|
|
100
165
|
justifyContent: "center",
|
|
101
|
-
paddingHorizontal:
|
|
166
|
+
paddingHorizontal: 12,
|
|
167
|
+
paddingVertical: 12,
|
|
168
|
+
},
|
|
169
|
+
alignEnd: {
|
|
170
|
+
alignItems: "flex-end",
|
|
102
171
|
},
|
|
103
172
|
flexColumn: {
|
|
104
173
|
flex: 1,
|