@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
package/README.md
CHANGED
|
@@ -46,3 +46,40 @@ if (file) {
|
|
|
46
46
|
}
|
|
47
47
|
}
|
|
48
48
|
```
|
|
49
|
+
|
|
50
|
+
## Binary reads, writes, and export
|
|
51
|
+
|
|
52
|
+
`files.readBytes(ref, { maxBytes?, signal? })` reads the full contents as a
|
|
53
|
+
`Uint8Array`. It works on `content://` picks (read through Android's
|
|
54
|
+
`ContentResolver`), iOS picker copies, sandbox files, and web object URLs.
|
|
55
|
+
`maxBytes` rejects oversized files mid-read instead of loading them, and
|
|
56
|
+
`signal` aborts the read with an `AbortError`.
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
const bytes = await files.readBytes(file, { maxBytes: 10 * 1024 * 1024 })
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`files.writeBytes(name, bytes)` mirrors `writeText`: on native it writes into
|
|
63
|
+
the app sandbox (Documents) and returns a `FileRef`; on web it starts a
|
|
64
|
+
browser download. `name` must be a bare file name — paths like `../x` or
|
|
65
|
+
`a/b` are rejected, and `writeText` enforces the same rule.
|
|
66
|
+
|
|
67
|
+
`files.export(name, bytes, { mimeType? })` asks the user where to save the
|
|
68
|
+
data and resolves `saved`, `cancelled`, or `unavailable`. `saved` means the
|
|
69
|
+
platform committed the bytes — Android flushes them through
|
|
70
|
+
`ContentResolver` to the picked document, iOS completes the export-as-copy
|
|
71
|
+
flow, and web closes a File System Access writable. Browsers without
|
|
72
|
+
`showSaveFilePicker` and the AppKit leaf report `unavailable`; a share-sheet
|
|
73
|
+
presentation never counts as `saved`. Only one export can run at a time — a
|
|
74
|
+
concurrent call rejects with an `ExportBusyError` rather than stealing the
|
|
75
|
+
open picker's result.
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
const result = await files.export('report.pdf', pdfBytes, { mimeType: 'application/pdf' })
|
|
79
|
+
if (result === 'cancelled') console.log('user dismissed')
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Picked refs are only guaranteed readable within the picking session. Android
|
|
83
|
+
`content://` grants are not persisted by the picker, and iOS import-mode
|
|
84
|
+
copies live in a temp folder — copy what you need with `writeBytes`, and
|
|
85
|
+
call `files.release(ref)` when done.
|
|
@@ -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 };
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { assertSafeFileName, concatBytes, exactBuffer, fileTooLarge, throwIfAborted } from "./file-bytes.js";
|
|
2
|
+
import { desktopHost } from "@octane-xplat/platform/host/web";
|
|
3
|
+
//#region src/files.web.ts
|
|
4
|
+
var objectBlobs = /* @__PURE__ */ new Map();
|
|
5
|
+
function mintObjectUrl(blob) {
|
|
6
|
+
const uri = URL.createObjectURL(blob);
|
|
7
|
+
objectBlobs.set(uri, blob);
|
|
8
|
+
return uri;
|
|
9
|
+
}
|
|
10
|
+
function chooseFiles(accept, multiple) {
|
|
11
|
+
return new Promise((resolve) => {
|
|
12
|
+
const input = document.createElement("input");
|
|
13
|
+
input.type = "file";
|
|
14
|
+
input.accept = accept;
|
|
15
|
+
input.multiple = multiple;
|
|
16
|
+
input.onchange = () => {
|
|
17
|
+
const refs = [];
|
|
18
|
+
for (const file of Array.from(input.files ?? [])) refs.push({
|
|
19
|
+
name: file.name,
|
|
20
|
+
uri: mintObjectUrl(file)
|
|
21
|
+
});
|
|
22
|
+
resolve(refs);
|
|
23
|
+
};
|
|
24
|
+
input.oncancel = () => resolve([]);
|
|
25
|
+
input.click();
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
async function fetchBytes(uri, opts) {
|
|
29
|
+
const response = await fetch(uri, { signal: opts?.signal });
|
|
30
|
+
if (!response.ok) throw new Error(`Unable to read file: ${uri}`);
|
|
31
|
+
if (opts?.maxBytes == null) return new Uint8Array(await response.arrayBuffer());
|
|
32
|
+
const reader = response.body?.getReader();
|
|
33
|
+
if (!reader) {
|
|
34
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
35
|
+
if (bytes.byteLength > opts.maxBytes) throw fileTooLarge(uri, opts.maxBytes);
|
|
36
|
+
return bytes;
|
|
37
|
+
}
|
|
38
|
+
const chunks = [];
|
|
39
|
+
let total = 0;
|
|
40
|
+
try {
|
|
41
|
+
while (true) {
|
|
42
|
+
const { done, value } = await reader.read();
|
|
43
|
+
if (done) break;
|
|
44
|
+
total += value.byteLength;
|
|
45
|
+
if (total > opts.maxBytes) {
|
|
46
|
+
await reader.cancel().catch(() => {});
|
|
47
|
+
throw fileTooLarge(uri, opts.maxBytes);
|
|
48
|
+
}
|
|
49
|
+
chunks.push(value);
|
|
50
|
+
}
|
|
51
|
+
} finally {
|
|
52
|
+
reader.releaseLock();
|
|
53
|
+
}
|
|
54
|
+
return concatBytes(chunks, total);
|
|
55
|
+
}
|
|
56
|
+
var files = {
|
|
57
|
+
async pickMultiple(accept = "*/*", _opts) {
|
|
58
|
+
return chooseFiles(accept, true);
|
|
59
|
+
},
|
|
60
|
+
async pick(accept = "*/*", opts) {
|
|
61
|
+
const host = desktopHost();
|
|
62
|
+
if (host && await host.supports("files", "pick")) return host.files.pick(accept, { startingFolder: opts?.startingFolder });
|
|
63
|
+
return (await chooseFiles(accept, false))[0] ?? null;
|
|
64
|
+
},
|
|
65
|
+
async readText(ref) {
|
|
66
|
+
const host = desktopHost();
|
|
67
|
+
if (host && await host.supports("files", "readText")) {
|
|
68
|
+
const text = await host.files.readText(ref.uri);
|
|
69
|
+
if (text === null) throw new Error(`Unable to read host file: ${ref.uri}`);
|
|
70
|
+
return text;
|
|
71
|
+
}
|
|
72
|
+
return (await fetch(ref.uri)).text();
|
|
73
|
+
},
|
|
74
|
+
async readBytes(ref, opts) {
|
|
75
|
+
throwIfAborted(opts?.signal);
|
|
76
|
+
const blob = objectBlobs.get(ref.uri);
|
|
77
|
+
if (blob) {
|
|
78
|
+
if (opts?.maxBytes != null && blob.size > opts.maxBytes) throw fileTooLarge(ref.uri, opts.maxBytes);
|
|
79
|
+
const bytes = new Uint8Array(await blob.arrayBuffer());
|
|
80
|
+
throwIfAborted(opts?.signal);
|
|
81
|
+
return bytes;
|
|
82
|
+
}
|
|
83
|
+
return fetchBytes(ref.uri, opts);
|
|
84
|
+
},
|
|
85
|
+
/** Desktop hosts write the selected path; plain web starts a download. */
|
|
86
|
+
async writeText(name, text) {
|
|
87
|
+
assertSafeFileName(name);
|
|
88
|
+
const host = desktopHost();
|
|
89
|
+
if (host && await host.supports("files", "writeText")) {
|
|
90
|
+
const file = await host.files.writeText(name, text);
|
|
91
|
+
if (!file) throw new Error("Host file save was cancelled");
|
|
92
|
+
return file;
|
|
93
|
+
}
|
|
94
|
+
const uri = mintObjectUrl(new Blob([text], { type: "text/plain" }));
|
|
95
|
+
const a = document.createElement("a");
|
|
96
|
+
a.href = uri;
|
|
97
|
+
a.download = name;
|
|
98
|
+
a.click();
|
|
99
|
+
return {
|
|
100
|
+
name,
|
|
101
|
+
uri
|
|
102
|
+
};
|
|
103
|
+
},
|
|
104
|
+
/** The desktop host contract is text-only, so bytes always download. */
|
|
105
|
+
async writeBytes(name, bytes) {
|
|
106
|
+
assertSafeFileName(name);
|
|
107
|
+
const uri = mintObjectUrl(new Blob([exactBuffer(bytes)], { type: "application/octet-stream" }));
|
|
108
|
+
const a = document.createElement("a");
|
|
109
|
+
a.href = uri;
|
|
110
|
+
a.download = name;
|
|
111
|
+
a.click();
|
|
112
|
+
return {
|
|
113
|
+
name,
|
|
114
|
+
uri
|
|
115
|
+
};
|
|
116
|
+
},
|
|
117
|
+
async export(name, bytes, opts) {
|
|
118
|
+
assertSafeFileName(name);
|
|
119
|
+
const picker = window.showSaveFilePicker;
|
|
120
|
+
if (typeof picker !== "function") return "unavailable";
|
|
121
|
+
const extension = name.includes(".") ? `.${name.split(".").pop()}` : null;
|
|
122
|
+
const types = opts?.mimeType && extension ? [{ accept: { [opts.mimeType]: [extension] } }] : void 0;
|
|
123
|
+
try {
|
|
124
|
+
const writable = await (await picker.call(window, {
|
|
125
|
+
suggestedName: name,
|
|
126
|
+
types
|
|
127
|
+
})).createWritable();
|
|
128
|
+
await writable.write(bytes);
|
|
129
|
+
await writable.close();
|
|
130
|
+
return "saved";
|
|
131
|
+
} catch (error) {
|
|
132
|
+
if (error?.name === "AbortError") return "cancelled";
|
|
133
|
+
throw error;
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
release(ref) {
|
|
137
|
+
if (ref.uri.startsWith("blob:")) {
|
|
138
|
+
objectBlobs.delete(ref.uri);
|
|
139
|
+
URL.revokeObjectURL(ref.uri);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
//#endregion
|
|
144
|
+
export { files };
|
|
File without changes
|
|
File without changes
|