@octane-xplat/files 0.10.0 → 0.11.1
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/README.md +37 -0
- package/dist/linux/FileInput.js +2 -0
- package/dist/linux/FileInput.web.js +263 -0
- package/dist/linux/file-bytes.js +45 -0
- package/dist/linux/file-input-utils.js +52 -0
- package/dist/linux/files.js +2 -0
- package/dist/linux/files.web.js +144 -0
- package/dist/linux/index.js +5 -0
- package/dist/linux/octane-client-build.json +8 -0
- package/dist/linux/props.js +0 -0
- package/dist/linux/types.js +0 -0
- package/dist/native/FileInput.js +434 -0
- package/dist/native/file-bytes.js +45 -0
- package/dist/native/file-input-utils.js +52 -0
- package/dist/native/files.js +283 -0
- package/dist/native/index.js +3 -0
- package/dist/native/octane-client-build.json +8 -0
- package/dist/native/props.js +0 -0
- package/dist/native/types.js +0 -0
- package/dist/web/FileInput.js +263 -0
- package/dist/web/file-bytes.js +45 -0
- package/dist/web/file-input-utils.js +52 -0
- package/dist/web/files.js +144 -0
- package/dist/web/index.js +3 -0
- package/dist/web/octane-client-build.json +8 -0
- package/dist/web/props.js +0 -0
- package/dist/web/types.js +0 -0
- package/package.json +50 -8
- package/src/file-bytes.test.ts +53 -0
- package/src/file-bytes.ts +68 -0
- package/src/files.bridge.test.ts +342 -0
- package/src/files.macos.ts +23 -2
- package/src/files.ts +329 -6
- package/src/files.web.test.ts +117 -0
- package/src/files.web.ts +133 -4
- package/src/types.ts +65 -2
- package/types/index.d.ts +8 -1
- package/types/index.macos.d.ts +8 -1
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { assertSafeFileName, exactBuffer, exportBusy, fileTooLarge, throwIfAborted } from "./file-bytes.js";
|
|
2
|
+
import { openFilePicker } from "@nativescript-community/ui-document-picker";
|
|
3
|
+
import { Application, File, Utils, knownFolders, path } from "@nativescript/core";
|
|
4
|
+
//#region src/files.ts
|
|
5
|
+
var docs = () => knownFolders.documents();
|
|
6
|
+
var READ_CHUNK = 65536;
|
|
7
|
+
var ANDROID_EXPORT_REQUEST_CODE = 1241;
|
|
8
|
+
function pickerTypes(accept) {
|
|
9
|
+
const values = accept.split(",").map((value) => value.trim()).filter(Boolean);
|
|
10
|
+
return {
|
|
11
|
+
extensions: values.filter((value) => value.startsWith(".")).map((value) => value.slice(1)),
|
|
12
|
+
mimeTypes: values.filter((value) => value.includes("/") && value !== "*/*")
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
function androidDisplayName(uri, nativeUri) {
|
|
16
|
+
if (!Application.android || !uri.startsWith("content://")) return;
|
|
17
|
+
try {
|
|
18
|
+
const activity = Application.android.foregroundActivity ?? Application.android.startActivity;
|
|
19
|
+
const noColumns = null;
|
|
20
|
+
const noFilter = null;
|
|
21
|
+
const cursor = activity?.getContentResolver()?.query(nativeUri ?? android.net.Uri.parse(uri), noColumns, noFilter, noColumns, noFilter);
|
|
22
|
+
if (!cursor) return;
|
|
23
|
+
try {
|
|
24
|
+
if (!cursor.moveToFirst()) return;
|
|
25
|
+
const index = cursor.getColumnIndex("display_name");
|
|
26
|
+
return index >= 0 ? cursor.getString(index) : void 0;
|
|
27
|
+
} finally {
|
|
28
|
+
cursor.close();
|
|
29
|
+
}
|
|
30
|
+
} catch {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function fallbackName(uri) {
|
|
35
|
+
const segment = uri.split("/").pop() ?? "";
|
|
36
|
+
try {
|
|
37
|
+
return decodeURIComponent(segment) || "document";
|
|
38
|
+
} catch {
|
|
39
|
+
return segment || "document";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
async function readContentUri(uri) {
|
|
43
|
+
const input = (Application.android?.foregroundActivity ?? Application.android?.startActivity)?.getContentResolver()?.openInputStream(android.net.Uri.parse(uri));
|
|
44
|
+
if (!input) throw new Error(`Unable to open picked file: ${uri}`);
|
|
45
|
+
const reader = new java.io.BufferedReader(new java.io.InputStreamReader(input));
|
|
46
|
+
const lines = [];
|
|
47
|
+
try {
|
|
48
|
+
let line;
|
|
49
|
+
while ((line = reader.readLine()) !== null) lines.push(line);
|
|
50
|
+
return lines.join("\n");
|
|
51
|
+
} finally {
|
|
52
|
+
reader.close();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
async function pickFiles(accept, multiple, opts) {
|
|
56
|
+
const { extensions, mimeTypes } = pickerTypes(accept);
|
|
57
|
+
const result = await openFilePicker({
|
|
58
|
+
extensions,
|
|
59
|
+
mimeTypes,
|
|
60
|
+
multipleSelection: multiple,
|
|
61
|
+
permissions: {
|
|
62
|
+
read: true,
|
|
63
|
+
persistable: true
|
|
64
|
+
},
|
|
65
|
+
startingFolder: opts?.startingFolder
|
|
66
|
+
});
|
|
67
|
+
return (result.files ?? []).map((uri, index) => ({
|
|
68
|
+
name: androidDisplayName(uri, index === 0 ? result.android : void 0) ?? fallbackName(uri),
|
|
69
|
+
uri
|
|
70
|
+
}));
|
|
71
|
+
}
|
|
72
|
+
function openAndroidInput(uri) {
|
|
73
|
+
const activity = Application.android?.foregroundActivity ?? Application.android?.startActivity;
|
|
74
|
+
if (uri.startsWith("content://") || uri.startsWith("file://")) return activity?.getContentResolver()?.openInputStream(android.net.Uri.parse(uri));
|
|
75
|
+
return new java.io.FileInputStream(uri);
|
|
76
|
+
}
|
|
77
|
+
function readAndroidBytes(uri, opts) {
|
|
78
|
+
const input = openAndroidInput(uri);
|
|
79
|
+
if (!input) throw new Error(`Unable to open picked file: ${uri}`);
|
|
80
|
+
const out = new java.io.ByteArrayOutputStream();
|
|
81
|
+
const chunk = Array.create("byte", READ_CHUNK);
|
|
82
|
+
try {
|
|
83
|
+
let count;
|
|
84
|
+
while ((count = input.read(chunk)) !== -1) {
|
|
85
|
+
throwIfAborted(opts?.signal);
|
|
86
|
+
out.write(chunk, 0, count);
|
|
87
|
+
if (opts?.maxBytes != null && out.size() > opts.maxBytes) throw fileTooLarge(uri, opts.maxBytes);
|
|
88
|
+
}
|
|
89
|
+
} finally {
|
|
90
|
+
input.close();
|
|
91
|
+
}
|
|
92
|
+
return new Uint8Array(ArrayBuffer.from(java.nio.ByteBuffer.wrap(out.toByteArray())));
|
|
93
|
+
}
|
|
94
|
+
function iosFilePath(uri) {
|
|
95
|
+
let p = uri.startsWith("file://") ? uri.slice(7) : uri;
|
|
96
|
+
if (NSFileManager.defaultManager.fileExistsAtPath(p)) return p;
|
|
97
|
+
try {
|
|
98
|
+
const decoded = decodeURIComponent(p);
|
|
99
|
+
if (decoded !== p && NSFileManager.defaultManager.fileExistsAtPath(decoded)) return decoded;
|
|
100
|
+
} catch {}
|
|
101
|
+
return p;
|
|
102
|
+
}
|
|
103
|
+
function readIosBytes(uri, opts) {
|
|
104
|
+
const p = iosFilePath(uri);
|
|
105
|
+
const handle = NSFileHandle.fileHandleForReadingAtPath(p);
|
|
106
|
+
if (!handle) throw new Error(`Unable to open picked file: ${uri}`);
|
|
107
|
+
const out = NSMutableData.new();
|
|
108
|
+
try {
|
|
109
|
+
while (true) {
|
|
110
|
+
throwIfAborted(opts?.signal);
|
|
111
|
+
const data = handle.readDataOfLength(READ_CHUNK);
|
|
112
|
+
if (!data || data.length === 0) break;
|
|
113
|
+
out.appendData(data);
|
|
114
|
+
if (opts?.maxBytes != null && out.length > opts.maxBytes) throw fileTooLarge(uri, opts.maxBytes);
|
|
115
|
+
}
|
|
116
|
+
} finally {
|
|
117
|
+
handle.closeFile();
|
|
118
|
+
}
|
|
119
|
+
return new Uint8Array(interop.bufferFromData(out));
|
|
120
|
+
}
|
|
121
|
+
function writeAndroidBytes(uri, bytes) {
|
|
122
|
+
const activity = Application.android?.foregroundActivity ?? Application.android?.startActivity;
|
|
123
|
+
const out = uri.startsWith("content://") || uri.startsWith("file://") ? activity?.getContentResolver()?.openOutputStream(android.net.Uri.parse(uri)) : new java.io.FileOutputStream(uri);
|
|
124
|
+
if (!out) throw new Error(`Unable to open destination: ${uri}`);
|
|
125
|
+
try {
|
|
126
|
+
java.nio.channels.Channels.newChannel(out).write(exactBuffer(bytes));
|
|
127
|
+
} finally {
|
|
128
|
+
out.close();
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
function androidExport(name, bytes, opts) {
|
|
132
|
+
const activity = Application.android?.foregroundActivity ?? Application.android?.startActivity;
|
|
133
|
+
if (!activity) return Promise.resolve("unavailable");
|
|
134
|
+
const Intent = android.content.Intent;
|
|
135
|
+
const intent = new Intent(Intent.ACTION_CREATE_DOCUMENT);
|
|
136
|
+
intent.addCategory(Intent.CATEGORY_OPENABLE);
|
|
137
|
+
intent.setType(opts?.mimeType ?? "*/*");
|
|
138
|
+
intent.putExtra(Intent.EXTRA_TITLE, name);
|
|
139
|
+
intent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION | Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
|
140
|
+
return new Promise((resolve, reject) => {
|
|
141
|
+
const onEvent = (event) => {
|
|
142
|
+
if (event.requestCode !== ANDROID_EXPORT_REQUEST_CODE) return;
|
|
143
|
+
Application.android.off(Application.android.activityResultEvent, onEvent);
|
|
144
|
+
const uri = event.intent?.getData?.();
|
|
145
|
+
if (event.resultCode !== android.app.Activity.RESULT_OK || !uri) {
|
|
146
|
+
resolve("cancelled");
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
try {
|
|
150
|
+
writeAndroidBytes(uri.toString(), bytes);
|
|
151
|
+
resolve("saved");
|
|
152
|
+
} catch (error) {
|
|
153
|
+
reject(error);
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
Application.android.on(Application.android.activityResultEvent, onEvent);
|
|
157
|
+
try {
|
|
158
|
+
activity.startActivityForResult(intent, ANDROID_EXPORT_REQUEST_CODE);
|
|
159
|
+
} catch (error) {
|
|
160
|
+
Application.android.off(Application.android.activityResultEvent, onEvent);
|
|
161
|
+
reject(error);
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
var activeExport = null;
|
|
166
|
+
var IosExportDelegate = null;
|
|
167
|
+
function iosExportDelegateClass() {
|
|
168
|
+
if (IosExportDelegate) return IosExportDelegate;
|
|
169
|
+
IosExportDelegate = class extends NSObject {
|
|
170
|
+
static ObjCProtocols = [UIDocumentPickerDelegate];
|
|
171
|
+
finish = null;
|
|
172
|
+
documentPickerDidPickDocumentAtURL(controller, _url) {
|
|
173
|
+
this.settle(controller, true);
|
|
174
|
+
}
|
|
175
|
+
documentPickerDidPickDocumentsAtURLs(controller, urls) {
|
|
176
|
+
this.settle(controller, urls.count > 0);
|
|
177
|
+
}
|
|
178
|
+
documentPickerWasCancelled(controller) {
|
|
179
|
+
this.settle(controller, false);
|
|
180
|
+
}
|
|
181
|
+
settle(controller, saved) {
|
|
182
|
+
const finish = this.finish;
|
|
183
|
+
this.finish = null;
|
|
184
|
+
controller.delegate = null;
|
|
185
|
+
finish?.(saved);
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
return IosExportDelegate;
|
|
189
|
+
}
|
|
190
|
+
function iosVisibleViewController() {
|
|
191
|
+
const app = UIApplication.sharedApplication;
|
|
192
|
+
const keyWindow = app.keyWindow || app.windows.count > 0 && app.windows[0];
|
|
193
|
+
const getVisible = Utils.ios?.getVisibleViewController;
|
|
194
|
+
return keyWindow && typeof getVisible === "function" ? getVisible(keyWindow.rootViewController) : null;
|
|
195
|
+
}
|
|
196
|
+
async function iosExport(name, bytes, slot) {
|
|
197
|
+
const tempPath = path.join(knownFolders.temp().path, name);
|
|
198
|
+
const staged = NSData.dataWithBytesLength(bytes, bytes.byteLength).writeToFileAtomically(tempPath, true);
|
|
199
|
+
const removeTemp = () => {
|
|
200
|
+
try {
|
|
201
|
+
File.fromPath(tempPath).removeSync();
|
|
202
|
+
} catch {}
|
|
203
|
+
};
|
|
204
|
+
if (!staged) throw new Error(`Failed to stage export file: ${name}`);
|
|
205
|
+
const visibleVC = iosVisibleViewController();
|
|
206
|
+
if (!visibleVC) {
|
|
207
|
+
removeTemp();
|
|
208
|
+
throw new Error("No visible view controller to present the export picker");
|
|
209
|
+
}
|
|
210
|
+
return new Promise((resolve, reject) => {
|
|
211
|
+
try {
|
|
212
|
+
const controller = UIDocumentPickerViewController.alloc().initForExportingURLsAsCopy([NSURL.fileURLWithPath(tempPath)], true);
|
|
213
|
+
const delegate = iosExportDelegateClass().new();
|
|
214
|
+
delegate.finish = (saved) => {
|
|
215
|
+
removeTemp();
|
|
216
|
+
resolve(saved ? "saved" : "cancelled");
|
|
217
|
+
};
|
|
218
|
+
slot.delegate = delegate;
|
|
219
|
+
slot.controller = controller;
|
|
220
|
+
controller.delegate = delegate;
|
|
221
|
+
controller.shouldShowFileExtensions = true;
|
|
222
|
+
visibleVC.presentViewControllerAnimatedCompletion(controller, true, null);
|
|
223
|
+
} catch (error) {
|
|
224
|
+
removeTemp();
|
|
225
|
+
reject(error);
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
var files = {
|
|
230
|
+
async pickMultiple(accept = "*/*", opts) {
|
|
231
|
+
return pickFiles(accept, true, opts);
|
|
232
|
+
},
|
|
233
|
+
async pick(accept = "*/*", opts) {
|
|
234
|
+
return (await pickFiles(accept, false, opts))[0] ?? null;
|
|
235
|
+
},
|
|
236
|
+
async readText(ref) {
|
|
237
|
+
if (ref.uri.startsWith("content://")) return readContentUri(ref.uri);
|
|
238
|
+
return File.fromPath(Application.android ? ref.uri : iosFilePath(ref.uri)).readText();
|
|
239
|
+
},
|
|
240
|
+
async readBytes(ref, opts) {
|
|
241
|
+
throwIfAborted(opts?.signal);
|
|
242
|
+
return Application.android ? readAndroidBytes(ref.uri, opts) : readIosBytes(ref.uri, opts);
|
|
243
|
+
},
|
|
244
|
+
async writeText(name, text) {
|
|
245
|
+
assertSafeFileName(name);
|
|
246
|
+
const p = path.join(docs().path, name);
|
|
247
|
+
File.fromPath(p).writeTextSync(text);
|
|
248
|
+
return {
|
|
249
|
+
name,
|
|
250
|
+
uri: p
|
|
251
|
+
};
|
|
252
|
+
},
|
|
253
|
+
async writeBytes(name, bytes) {
|
|
254
|
+
assertSafeFileName(name);
|
|
255
|
+
const p = path.join(docs().path, name);
|
|
256
|
+
if (Application.android) writeAndroidBytes(p, bytes);
|
|
257
|
+
else if (!NSData.dataWithBytesLength(bytes, bytes.byteLength).writeToFileAtomically(p, true)) throw new Error(`Failed to write file: ${p}`);
|
|
258
|
+
return {
|
|
259
|
+
name,
|
|
260
|
+
uri: p
|
|
261
|
+
};
|
|
262
|
+
},
|
|
263
|
+
async export(name, bytes, opts) {
|
|
264
|
+
assertSafeFileName(name);
|
|
265
|
+
if (activeExport) throw exportBusy();
|
|
266
|
+
const slot = {};
|
|
267
|
+
activeExport = slot;
|
|
268
|
+
try {
|
|
269
|
+
return Application.android ? await androidExport(name, bytes, opts) : await iosExport(name, bytes, slot);
|
|
270
|
+
} finally {
|
|
271
|
+
if (activeExport === slot) activeExport = null;
|
|
272
|
+
}
|
|
273
|
+
},
|
|
274
|
+
release(ref) {
|
|
275
|
+
const cachePrefix = knownFolders.temp().path + "/";
|
|
276
|
+
if (!ref.uri.startsWith(cachePrefix)) return;
|
|
277
|
+
try {
|
|
278
|
+
File.fromPath(ref.uri).removeSync();
|
|
279
|
+
} catch {}
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
//#endregion
|
|
283
|
+
export { files };
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { cx, formatFileSize, normalizePicked, validateFiles } from "./file-input-utils.js";
|
|
2
|
+
import { Fragment, __s, bag5, clone, createElement, createScopedElement, createScopedValue, delegateEvents, evt1, hookSlots, positionalChildren, setEventHandler, setStyle, template, useId, useImperativeHandle, useRef, useState, withSlot } from "octane";
|
|
3
|
+
import { bindSignalAttribute, bindSignalChild, createElementFromConfig, enableSignalBindings } from "octane/internal/client";
|
|
4
|
+
import { Field, Icon, Spinner, Tooltip, useFieldControl } from "@octane-xplat/ui";
|
|
5
|
+
//#region src/FileInput.web.tsrx
|
|
6
|
+
enableSignalBindings(1, true);
|
|
7
|
+
delegateEvents([
|
|
8
|
+
"dragenter",
|
|
9
|
+
"dragleave",
|
|
10
|
+
"dragover",
|
|
11
|
+
"drop"
|
|
12
|
+
]);
|
|
13
|
+
var _t$0 = /* @__PURE__ */ template("<div></div>");
|
|
14
|
+
var _hs$ = /* @__PURE__ */ hookSlots(9);
|
|
15
|
+
var _h$0 = /* @__PURE__ */ Symbol(_hs$);
|
|
16
|
+
var _h$7 = /* @__PURE__ */ Symbol(_hs$ + 7);
|
|
17
|
+
var STATUS_GLYPHS = {
|
|
18
|
+
error: "⚠",
|
|
19
|
+
warning: "⚠",
|
|
20
|
+
success: "✓"
|
|
21
|
+
};
|
|
22
|
+
var FileInputControl = /* @__PURE__ */ __s(function FileInputControl(inputProps, __s, __extra) {
|
|
23
|
+
const { props } = withSlot(_h$0, useFieldControl, inputProps);
|
|
24
|
+
const generatedId = useId(_hs$ + 1);
|
|
25
|
+
const inputId = props.id ?? generatedId;
|
|
26
|
+
const fileInput = useRef(null, _hs$ + 2);
|
|
27
|
+
const [dragging, setDragging] = useState(false, _hs$ + 3);
|
|
28
|
+
const [busy, setBusy] = useState(false, _hs$ + 4);
|
|
29
|
+
const [validationErrors, setValidationErrors] = useState([], _hs$ + 5);
|
|
30
|
+
const files = props.value == null ? [] : Array.isArray(props.value) ? props.value : [props.value];
|
|
31
|
+
const isDisabled = !!props.isDisabled || !!props.isReadOnly;
|
|
32
|
+
const isBusy = !!props.isLoading || busy;
|
|
33
|
+
const canClear = files.length > 0 && !isDisabled && !isBusy;
|
|
34
|
+
const showsDisabledReason = isDisabled && !!props.disabledMessage;
|
|
35
|
+
const statusType = validationErrors.length ? "error" : props.status?.type;
|
|
36
|
+
const statusMessage = validationErrors.length ? validationErrors.join(" ") : props.status?.message;
|
|
37
|
+
const emitValue = (next) => props.isMultiple ? next : next[0] ?? null;
|
|
38
|
+
const fireChange = (next) => {
|
|
39
|
+
const emitted = emitValue(next);
|
|
40
|
+
props.onChange?.(emitted);
|
|
41
|
+
if (props.changeAction) {
|
|
42
|
+
setBusy(true);
|
|
43
|
+
Promise.resolve(props.changeAction(emitted)).finally(() => setBusy(false));
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
const acceptRefs = (picked) => {
|
|
47
|
+
if (isDisabled || isBusy || !picked.length) return;
|
|
48
|
+
const merged = props.isMultiple ? [...files, ...picked] : picked;
|
|
49
|
+
const { valid, errors } = validateFiles(merged, {
|
|
50
|
+
accept: props.accept,
|
|
51
|
+
maxSize: props.maxSize,
|
|
52
|
+
maxFiles: props.maxFiles
|
|
53
|
+
});
|
|
54
|
+
setValidationErrors(errors);
|
|
55
|
+
const next = !props.isMultiple && errors.length ? files : valid;
|
|
56
|
+
if (next.length !== files.length || next.some((file, index) => file.uri !== files[index]?.uri)) fireChange(next);
|
|
57
|
+
};
|
|
58
|
+
const removeAt = (index) => {
|
|
59
|
+
if (isDisabled || isBusy) return;
|
|
60
|
+
setValidationErrors([]);
|
|
61
|
+
fireChange(files.filter((_, i) => i !== index));
|
|
62
|
+
};
|
|
63
|
+
const pick = () => {
|
|
64
|
+
if (isDisabled || isBusy) return;
|
|
65
|
+
if (props.pick) {
|
|
66
|
+
Promise.resolve(props.pick({
|
|
67
|
+
accept: props.accept,
|
|
68
|
+
multiple: props.isMultiple
|
|
69
|
+
})).then((r) => acceptRefs(normalizePicked(r))).catch((error) => setValidationErrors([String(error?.message ?? error)]));
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
fileInput.current?.click();
|
|
73
|
+
};
|
|
74
|
+
const handleFileSelect = (e) => {
|
|
75
|
+
const list = e.target.files;
|
|
76
|
+
const picked = [];
|
|
77
|
+
if (list) for (let i = 0; i < list.length; i++) {
|
|
78
|
+
const f = list[i];
|
|
79
|
+
picked.push({
|
|
80
|
+
name: f.name,
|
|
81
|
+
uri: URL.createObjectURL(f),
|
|
82
|
+
size: f.size,
|
|
83
|
+
mimeType: f.type,
|
|
84
|
+
file: f
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
e.target.value = "";
|
|
88
|
+
acceptRefs(picked);
|
|
89
|
+
};
|
|
90
|
+
const handleDrop = (e) => {
|
|
91
|
+
e.preventDefault();
|
|
92
|
+
e.stopPropagation();
|
|
93
|
+
setDragging(false);
|
|
94
|
+
if (isDisabled || isBusy) return;
|
|
95
|
+
const list = e.dataTransfer?.files;
|
|
96
|
+
if (!list?.length) return;
|
|
97
|
+
const picked = [];
|
|
98
|
+
for (let i = 0; i < list.length; i++) {
|
|
99
|
+
const f = list[i];
|
|
100
|
+
picked.push({
|
|
101
|
+
name: f.name,
|
|
102
|
+
uri: URL.createObjectURL(f),
|
|
103
|
+
size: f.size,
|
|
104
|
+
mimeType: f.type,
|
|
105
|
+
file: f
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
acceptRefs(picked);
|
|
109
|
+
};
|
|
110
|
+
const dropzone = props.mode === "dropzone";
|
|
111
|
+
const itemContent = createScopedElement(Fragment, {}, () => positionalChildren([
|
|
112
|
+
files.length ? createScopedElement("ul", { className: "vx-fileinput-list" }, () => files.map((f, i) => createScopedElement("li", {
|
|
113
|
+
key: `${f.uri}-${i}`,
|
|
114
|
+
className: "vx-fileinput-item"
|
|
115
|
+
}, () => positionalChildren([
|
|
116
|
+
createElementFromConfig("c:b0d78e7b", Icon, {
|
|
117
|
+
name: "xplat-upload",
|
|
118
|
+
size: 14
|
|
119
|
+
}),
|
|
120
|
+
createScopedElement("span", { className: "vx-fileinput-itemname" }, () => f.name),
|
|
121
|
+
f.size != null ? createScopedElement("span", { className: "vx-fileinput-itemsize" }, () => formatFileSize(f.size)) : null,
|
|
122
|
+
!isDisabled && !isBusy ? createScopedValue(() => createElement("button", {
|
|
123
|
+
type: "button",
|
|
124
|
+
className: "vx-fileinput-itemremove",
|
|
125
|
+
"aria-label": `Remove ${f.name}`,
|
|
126
|
+
onClick: () => removeAt(i)
|
|
127
|
+
}, "×")) : null
|
|
128
|
+
])))) : null,
|
|
129
|
+
dropzone ? createScopedElement("div", { className: "vx-fileinput-dropzone-hint" }, () => positionalChildren([
|
|
130
|
+
createElementFromConfig("c:928c8dae", Icon, {
|
|
131
|
+
name: "xplat-upload",
|
|
132
|
+
size: 24
|
|
133
|
+
}),
|
|
134
|
+
createScopedElement("span", {}, () => isDisabled ? "Upload disabled" : "Drag & drop files here or click to browse"),
|
|
135
|
+
props.accept ? createScopedElement("span", { className: "vx-fileinput-accept" }, () => props.accept) : null
|
|
136
|
+
])) : createScopedValue(() => createScopedElement("button", {
|
|
137
|
+
type: "button",
|
|
138
|
+
id: inputId,
|
|
139
|
+
className: "vx-fileinput-trigger",
|
|
140
|
+
disabled: isDisabled && !showsDisabledReason || void 0,
|
|
141
|
+
"aria-disabled": showsDisabledReason || void 0,
|
|
142
|
+
"aria-describedby": props.ariaDescribedBy,
|
|
143
|
+
"aria-required": props.isRequired && !props.isOptional || void 0,
|
|
144
|
+
"aria-invalid": statusType === "error" || void 0,
|
|
145
|
+
"aria-busy": isBusy || void 0,
|
|
146
|
+
onClick: pick
|
|
147
|
+
}, () => positionalChildren([
|
|
148
|
+
createElementFromConfig("c:a0fe8d9f", Icon, {
|
|
149
|
+
name: "xplat-upload",
|
|
150
|
+
size: 14
|
|
151
|
+
}),
|
|
152
|
+
" ",
|
|
153
|
+
files.length ? "Choose more files" : "Choose files"
|
|
154
|
+
]))),
|
|
155
|
+
dropzone ? createScopedValue(() => createElement("button", {
|
|
156
|
+
type: "button",
|
|
157
|
+
id: inputId,
|
|
158
|
+
className: "vx-fileinput-trigger vx-fileinput-trigger--hidden",
|
|
159
|
+
disabled: isDisabled && !showsDisabledReason || void 0,
|
|
160
|
+
"aria-disabled": showsDisabledReason || void 0,
|
|
161
|
+
"aria-describedby": props.ariaDescribedBy,
|
|
162
|
+
"aria-required": props.isRequired && !props.isOptional || void 0,
|
|
163
|
+
"aria-invalid": statusType === "error" || void 0,
|
|
164
|
+
"aria-busy": isBusy || void 0,
|
|
165
|
+
onClick: pick
|
|
166
|
+
}, "Browse files")) : null,
|
|
167
|
+
canClear ? createScopedValue(() => createElement("button", {
|
|
168
|
+
type: "button",
|
|
169
|
+
className: "vx-fileinput-clear",
|
|
170
|
+
"aria-label": `Clear ${props.fieldLabel ?? props.label ?? "files"}`,
|
|
171
|
+
onClick: () => {
|
|
172
|
+
setValidationErrors([]);
|
|
173
|
+
fireChange([]);
|
|
174
|
+
}
|
|
175
|
+
}, "×")) : null,
|
|
176
|
+
isBusy ? createElementFromConfig("c:47d00c47", Spinner, {
|
|
177
|
+
busy: true,
|
|
178
|
+
className: "vx-fileinput-spinner"
|
|
179
|
+
}) : null,
|
|
180
|
+
statusType ? props.statusVariant === "tooltip" && statusMessage ? createScopedValue(() => createElementFromConfig("c:86665b9b", Tooltip, {
|
|
181
|
+
trigger: createScopedValue(() => createScopedElement("span", { className: cx("vx-field-icon", `vx-field-icon--${statusType}`) }, () => STATUS_GLYPHS[statusType])),
|
|
182
|
+
content: statusMessage
|
|
183
|
+
})) : createScopedValue(() => createScopedElement("span", { className: cx("vx-field-icon", `vx-field-icon--${statusType}`) }, () => STATUS_GLYPHS[statusType])) : null,
|
|
184
|
+
statusMessage && props.statusVariant !== "tooltip" ? createScopedValue(() => createScopedElement("span", {
|
|
185
|
+
className: cx("vx-field-status", `vx-field-status--${statusType}`),
|
|
186
|
+
role: statusType === "error" ? "alert" : "status"
|
|
187
|
+
}, () => statusMessage)) : null,
|
|
188
|
+
createScopedValue(() => createElement("input", {
|
|
189
|
+
ref: (el) => {
|
|
190
|
+
fileInput.current = el;
|
|
191
|
+
},
|
|
192
|
+
type: "file",
|
|
193
|
+
className: "vx-fileinput-nativeinput",
|
|
194
|
+
accept: props.accept,
|
|
195
|
+
multiple: props.isMultiple || void 0,
|
|
196
|
+
disabled: isDisabled || void 0,
|
|
197
|
+
"aria-hidden": "true",
|
|
198
|
+
tabIndex: -1,
|
|
199
|
+
onChange: handleFileSelect
|
|
200
|
+
}))
|
|
201
|
+
]));
|
|
202
|
+
useImperativeHandle(props.ref, () => ({
|
|
203
|
+
open: pick,
|
|
204
|
+
native: fileInput.current
|
|
205
|
+
}), void 0, _hs$ + 6);
|
|
206
|
+
if (__s.block.pending && !__s.block.crossRenderUpdate) return;
|
|
207
|
+
let _b = __s.slots[0];
|
|
208
|
+
if (_b === void 0) {
|
|
209
|
+
let _m1;
|
|
210
|
+
const _root = clone(_t$0);
|
|
211
|
+
_m1 = _root;
|
|
212
|
+
setEventHandler(_root, "$$dragover", (e) => {
|
|
213
|
+
e.preventDefault();
|
|
214
|
+
if (!isDisabled && !isBusy) setDragging(true);
|
|
215
|
+
});
|
|
216
|
+
setEventHandler(_root, "$$dragenter", (e) => {
|
|
217
|
+
e.preventDefault();
|
|
218
|
+
});
|
|
219
|
+
evt1(_root, "$$dragleave", setDragging, false);
|
|
220
|
+
setEventHandler(_root, "$$drop", handleDrop);
|
|
221
|
+
_b = bag5(__s, _root, void 0, _m1, void 0, null, void 0);
|
|
222
|
+
} else {
|
|
223
|
+
setEventHandler(_b.b, "$$dragover", (e) => {
|
|
224
|
+
e.preventDefault();
|
|
225
|
+
if (!isDisabled && !isBusy) setDragging(true);
|
|
226
|
+
});
|
|
227
|
+
setEventHandler(_b.b, "$$drop", handleDrop);
|
|
228
|
+
}
|
|
229
|
+
_b.a = bindSignalAttribute(__s, _b.a, _b.b, "class", cx("vx-fileinput", dropzone && "vx-fileinput--dropzone", isDisabled && "vx-fileinput--disabled", isBusy && "vx-fileinput--loading", dragging && "vx-fileinput--dragging", statusType && `vx-fileinput--status-${statusType}`, props.size && `vx-fileinput--size-${props.size}`, props.className), "b:e1094c29", "class");
|
|
230
|
+
{
|
|
231
|
+
const _v = props.style;
|
|
232
|
+
if (_b.c !== _v) {
|
|
233
|
+
setStyle(_b.b, _v, _b.c);
|
|
234
|
+
_b.c = _v;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
_b.f = bindSignalChild(__s, _b.f, 1, _b.b, itemContent, "b:11d99fad", null, void 0, void 0, void 0, true);
|
|
238
|
+
});
|
|
239
|
+
var FileInput = function FileInput(props, __s, __extra) {
|
|
240
|
+
const { inField } = withSlot(_h$7, useFieldControl, props);
|
|
241
|
+
const cid = useId(_hs$ + 8);
|
|
242
|
+
const control = createScopedValue(() => createElementFromConfig("c:afa4c501", FileInputControl, { ...props }));
|
|
243
|
+
if (inField) return control ?? null;
|
|
244
|
+
return createScopedValue(() => createScopedElement(Field, {
|
|
245
|
+
inputID: props.id ?? cid,
|
|
246
|
+
label: props.label,
|
|
247
|
+
isLabelHidden: props.isLabelHidden,
|
|
248
|
+
description: props.description,
|
|
249
|
+
isDisabled: props.isDisabled,
|
|
250
|
+
disabledMessage: props.disabledMessage,
|
|
251
|
+
isReadOnly: props.isReadOnly,
|
|
252
|
+
isLoading: props.isLoading,
|
|
253
|
+
isRequired: props.isRequired,
|
|
254
|
+
isOptional: props.isOptional,
|
|
255
|
+
size: props.size,
|
|
256
|
+
status: props.status,
|
|
257
|
+
statusVariant: props.statusVariant,
|
|
258
|
+
labelTooltip: props.labelTooltip,
|
|
259
|
+
width: props.width
|
|
260
|
+
}, () => control, "c:3f29a6d4"));
|
|
261
|
+
};
|
|
262
|
+
//#endregion
|
|
263
|
+
export { FileInput };
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
//#region src/file-bytes.ts
|
|
2
|
+
/**
|
|
3
|
+
* Shared helpers for the binary file primitives (readBytes/writeBytes/export).
|
|
4
|
+
* DOM-free so the native leaf can use it too.
|
|
5
|
+
*/
|
|
6
|
+
/** File names used for sandbox writes and export temp files must be bare
|
|
7
|
+
* names, not paths — a `/`, `\`, or `..` segment escapes the sandbox. */
|
|
8
|
+
function assertSafeFileName(name) {
|
|
9
|
+
if (typeof name !== "string" || name.length === 0 || name === "." || name === ".." || name.includes("/") || name.includes("\\") || name.includes("\0")) throw new Error(`Unsafe file name: ${JSON.stringify(name)}`);
|
|
10
|
+
}
|
|
11
|
+
function fileTooLarge(name, maxBytes) {
|
|
12
|
+
return /* @__PURE__ */ new Error(`${name}: file exceeds the ${maxBytes}-byte limit`);
|
|
13
|
+
}
|
|
14
|
+
/** Named rejection for a second `files.export` while one is in flight. */
|
|
15
|
+
function exportBusy() {
|
|
16
|
+
const error = /* @__PURE__ */ new Error("files.export: an export is already in progress");
|
|
17
|
+
error.name = "ExportBusyError";
|
|
18
|
+
return error;
|
|
19
|
+
}
|
|
20
|
+
function throwIfAborted(signal) {
|
|
21
|
+
if (!signal?.aborted) return;
|
|
22
|
+
const thrower = signal.throwIfAborted;
|
|
23
|
+
if (typeof thrower === "function") thrower.call(signal);
|
|
24
|
+
const error = /* @__PURE__ */ new Error("The operation was aborted");
|
|
25
|
+
error.name = "AbortError";
|
|
26
|
+
throw error;
|
|
27
|
+
}
|
|
28
|
+
/** Concatenate Uint8Array chunks into one exact-length array. */
|
|
29
|
+
function concatBytes(chunks, total) {
|
|
30
|
+
const out = new Uint8Array(total);
|
|
31
|
+
let offset = 0;
|
|
32
|
+
for (const chunk of chunks) {
|
|
33
|
+
out.set(chunk, offset);
|
|
34
|
+
offset += chunk.byteLength;
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
/** Return an ArrayBuffer covering exactly this view's byte range — native
|
|
39
|
+
* marshallers treat the argument as the whole buffer. */
|
|
40
|
+
function exactBuffer(bytes) {
|
|
41
|
+
if (bytes.byteOffset === 0 && bytes.byteLength === bytes.buffer.byteLength) return bytes.buffer;
|
|
42
|
+
return bytes.slice().buffer;
|
|
43
|
+
}
|
|
44
|
+
//#endregion
|
|
45
|
+
export { assertSafeFileName, concatBytes, exactBuffer, exportBusy, fileTooLarge, throwIfAborted };
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
//#region src/file-input-utils.ts
|
|
2
|
+
function cx(...values) {
|
|
3
|
+
return values.filter(Boolean).join(" ");
|
|
4
|
+
}
|
|
5
|
+
function normalizePicked(result) {
|
|
6
|
+
if (result == null) return [];
|
|
7
|
+
return Array.isArray(result) ? result : [result];
|
|
8
|
+
}
|
|
9
|
+
function fileAccepts(file, accept) {
|
|
10
|
+
if (!accept) return true;
|
|
11
|
+
const tokens = accept.split(",").map((t) => t.trim().toLowerCase()).filter(Boolean);
|
|
12
|
+
if (!tokens.length) return true;
|
|
13
|
+
const name = file.name.toLowerCase();
|
|
14
|
+
const mime = (file.mimeType ?? "").toLowerCase();
|
|
15
|
+
return tokens.some((token) => {
|
|
16
|
+
if (token === "*/*") return true;
|
|
17
|
+
if (token.startsWith(".")) return name.endsWith(token);
|
|
18
|
+
if (token.endsWith("/*")) return mime.startsWith(token.slice(0, -1));
|
|
19
|
+
return mime === token;
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
function formatFileSize(bytes) {
|
|
23
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
24
|
+
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
25
|
+
if (bytes < 1073741824) return `${(bytes / 1048576).toFixed(1)} MB`;
|
|
26
|
+
return `${(bytes / 1073741824).toFixed(1)} GB`;
|
|
27
|
+
}
|
|
28
|
+
function validateFiles(files, opts) {
|
|
29
|
+
const errors = [];
|
|
30
|
+
const valid = [];
|
|
31
|
+
for (const file of files) {
|
|
32
|
+
if (!fileAccepts(file, opts.accept)) {
|
|
33
|
+
errors.push(`${file.name}: file type not accepted`);
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (opts.maxSize != null && file.size != null && file.size > opts.maxSize) {
|
|
37
|
+
errors.push(`${file.name}: file exceeds ${formatFileSize(opts.maxSize)}`);
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
valid.push(file);
|
|
41
|
+
}
|
|
42
|
+
if (opts.maxFiles != null && valid.length > opts.maxFiles) {
|
|
43
|
+
const extra = valid.splice(opts.maxFiles);
|
|
44
|
+
for (const file of extra) errors.push(`${file.name}: exceeds the ${opts.maxFiles}-file limit`);
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
valid,
|
|
48
|
+
errors
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
//#endregion
|
|
52
|
+
export { cx, fileAccepts, formatFileSize, normalizePicked, validateFiles };
|