@octane-xplat/files 0.0.1 → 0.10.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/LICENSE +21 -0
- package/README.md +47 -2
- package/package.json +68 -10
- package/src/FileInput.linux.tsrx +1 -0
- package/src/FileInput.macos.tsrx +167 -0
- package/src/FileInput.tsrx +168 -0
- package/src/FileInput.web.test.tsx +99 -0
- package/src/FileInput.web.tsrx +240 -0
- package/src/file-input-utils.test.ts +22 -0
- package/src/file-input-utils.ts +113 -0
- package/src/files.linux.ts +2 -0
- package/src/files.macos.ts +59 -0
- package/src/files.ts +141 -0
- package/src/files.web.ts +75 -0
- package/src/index.macos.ts +11 -0
- package/src/index.ts +11 -0
- package/src/props.ts +52 -0
- package/src/types.ts +9 -0
- package/types/index.d.ts +15 -0
- package/types/index.macos.d.ts +14 -0
- package/index.d.ts +0 -1
- package/index.js +0 -2
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { useImperativeHandle, useId, useRef, useState } from 'octane'
|
|
2
|
+
import { Field as FormField, Icon, Spinner as ActivityIndicator, Tooltip, useFieldControl } from '@octane-xplat/ui'
|
|
3
|
+
import type { FileInputFile, FileInputProps } from './props'
|
|
4
|
+
import { cx, formatFileSize, normalizePicked, validateFiles } from './file-input-utils'
|
|
5
|
+
|
|
6
|
+
const STATUS_GLYPHS = { error: '⚠', warning: '⚠', success: '✓' } as const
|
|
7
|
+
|
|
8
|
+
function FileInputControl(inputProps: FileInputProps) @{
|
|
9
|
+
const { props } = useFieldControl(inputProps)
|
|
10
|
+
const generatedId = useId()
|
|
11
|
+
const inputId = props.id ?? generatedId
|
|
12
|
+
const fileInput = useRef<HTMLInputElement | null>(null)
|
|
13
|
+
|
|
14
|
+
const [dragging, setDragging] = useState(false)
|
|
15
|
+
const [busy, setBusy] = useState(false)
|
|
16
|
+
const [validationErrors, setValidationErrors] = useState<string[]>([])
|
|
17
|
+
|
|
18
|
+
const files: FileInputFile[] = props.value == null ? [] : Array.isArray(props.value) ? props.value : [props.value]
|
|
19
|
+
const isDisabled = !!props.isDisabled || !!props.isReadOnly
|
|
20
|
+
const isBusy = !!props.isLoading || busy
|
|
21
|
+
const canClear = files.length > 0 && !isDisabled && !isBusy
|
|
22
|
+
const showsDisabledReason = isDisabled && !!props.disabledMessage
|
|
23
|
+
const statusType = validationErrors.length ? 'error' : props.status?.type
|
|
24
|
+
const statusMessage = validationErrors.length ? validationErrors.join(' ') : props.status?.message
|
|
25
|
+
|
|
26
|
+
// `onChange` mirrors `value`'s shape: array when `isMultiple`, single ref
|
|
27
|
+
// or null otherwise.
|
|
28
|
+
const emitValue = (next: FileInputFile[]): FileInputFile | FileInputFile[] | null =>
|
|
29
|
+
props.isMultiple ? next : (next[0] ?? null)
|
|
30
|
+
|
|
31
|
+
const fireChange = (next: FileInputFile[]) => {
|
|
32
|
+
const emitted = emitValue(next)
|
|
33
|
+
props.onChange?.(emitted)
|
|
34
|
+
if (props.changeAction) {
|
|
35
|
+
setBusy(true)
|
|
36
|
+
Promise.resolve(props.changeAction(emitted)).finally(() => setBusy(false))
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const acceptRefs = (picked: FileInputFile[]) => {
|
|
41
|
+
if (isDisabled || isBusy || !picked.length) return
|
|
42
|
+
const merged = props.isMultiple ? [...files, ...picked] : picked
|
|
43
|
+
const { valid, errors } = validateFiles(merged, {
|
|
44
|
+
accept: props.accept,
|
|
45
|
+
maxSize: props.maxSize,
|
|
46
|
+
maxFiles: props.maxFiles,
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
setValidationErrors(errors)
|
|
50
|
+
const next = !props.isMultiple && errors.length ? files : valid
|
|
51
|
+
if (next.length !== files.length || next.some((file, index) => file.uri !== files[index]?.uri)) {
|
|
52
|
+
fireChange(next)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const removeAt = (index: number) => {
|
|
57
|
+
if (isDisabled || isBusy) return
|
|
58
|
+
setValidationErrors([])
|
|
59
|
+
fireChange(files.filter((_, i) => i !== index))
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const pick = () => {
|
|
63
|
+
if (isDisabled || isBusy) return
|
|
64
|
+
// An optional custom picker can provide cloud or app-specific sources.
|
|
65
|
+
if (props.pick) {
|
|
66
|
+
Promise.resolve(props.pick({ accept: props.accept, multiple: props.isMultiple }))
|
|
67
|
+
.then((r) => acceptRefs(normalizePicked(r)))
|
|
68
|
+
.catch((error) => setValidationErrors([String(error?.message ?? error)]))
|
|
69
|
+
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
fileInput.current?.click()
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const handleFileSelect = (e: any) => {
|
|
77
|
+
const list = e.target.files as any
|
|
78
|
+
const picked: FileInputFile[] = []
|
|
79
|
+
if (list) {
|
|
80
|
+
for (let i = 0; i < list.length; i++) {
|
|
81
|
+
const f = list[i]
|
|
82
|
+
picked.push({ name: f.name, uri: URL.createObjectURL(f), size: f.size, mimeType: f.type, file: f })
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
e.target.value = ''
|
|
87
|
+
acceptRefs(picked)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const handleDrop = (e: any) => {
|
|
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: FileInputFile[] = []
|
|
98
|
+
for (let i = 0; i < list.length; i++) {
|
|
99
|
+
const f = list[i]
|
|
100
|
+
picked.push({ name: f.name, uri: URL.createObjectURL(f), size: f.size, mimeType: f.type, file: f })
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
acceptRefs(picked)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const dropzone = props.mode === 'dropzone'
|
|
107
|
+
const itemContent = (
|
|
108
|
+
<>
|
|
109
|
+
{files.length ? (
|
|
110
|
+
<ul className="vx-fileinput-list">
|
|
111
|
+
{files.map((f: FileInputFile, i: number) => (
|
|
112
|
+
<li key={`${f.uri}-${i}`} className="vx-fileinput-item">
|
|
113
|
+
<Icon name="xplat-upload" size={14} />
|
|
114
|
+
<span className="vx-fileinput-itemname">{f.name}</span>
|
|
115
|
+
{f.size != null ? <span className="vx-fileinput-itemsize">{formatFileSize(f.size)}</span> : null}
|
|
116
|
+
{!isDisabled && !isBusy
|
|
117
|
+
? <button type="button" className="vx-fileinput-itemremove" aria-label={`Remove ${f.name}`} onClick={() => removeAt(i)}>{'×'}</button>
|
|
118
|
+
: null}
|
|
119
|
+
</li>
|
|
120
|
+
))}
|
|
121
|
+
</ul>
|
|
122
|
+
) : null}
|
|
123
|
+
{dropzone ? (
|
|
124
|
+
<div className="vx-fileinput-dropzone-hint">
|
|
125
|
+
<Icon name="xplat-upload" size={24} />
|
|
126
|
+
<span>{isDisabled ? 'Upload disabled' : 'Drag & drop files here or click to browse'}</span>
|
|
127
|
+
{props.accept ? <span className="vx-fileinput-accept">{props.accept}</span> : null}
|
|
128
|
+
</div>
|
|
129
|
+
) : (
|
|
130
|
+
<button
|
|
131
|
+
type="button"
|
|
132
|
+
id={inputId}
|
|
133
|
+
className="vx-fileinput-trigger"
|
|
134
|
+
disabled={isDisabled && !showsDisabledReason || undefined}
|
|
135
|
+
aria-disabled={showsDisabledReason || undefined}
|
|
136
|
+
aria-describedby={props.ariaDescribedBy}
|
|
137
|
+
aria-required={props.isRequired && !props.isOptional || undefined}
|
|
138
|
+
aria-invalid={statusType === 'error' || undefined}
|
|
139
|
+
aria-busy={isBusy || undefined}
|
|
140
|
+
onClick={pick}
|
|
141
|
+
>
|
|
142
|
+
<Icon name="xplat-upload" size={14} /> {files.length ? 'Choose more files' : 'Choose files'}
|
|
143
|
+
</button>
|
|
144
|
+
)}
|
|
145
|
+
{dropzone ? (
|
|
146
|
+
<button
|
|
147
|
+
type="button"
|
|
148
|
+
id={inputId}
|
|
149
|
+
className="vx-fileinput-trigger vx-fileinput-trigger--hidden"
|
|
150
|
+
disabled={isDisabled && !showsDisabledReason || undefined}
|
|
151
|
+
aria-disabled={showsDisabledReason || undefined}
|
|
152
|
+
aria-describedby={props.ariaDescribedBy}
|
|
153
|
+
aria-required={props.isRequired && !props.isOptional || undefined}
|
|
154
|
+
aria-invalid={statusType === 'error' || undefined}
|
|
155
|
+
aria-busy={isBusy || undefined}
|
|
156
|
+
onClick={pick}
|
|
157
|
+
>
|
|
158
|
+
Browse files
|
|
159
|
+
</button>
|
|
160
|
+
) : null}
|
|
161
|
+
{canClear ? <button type="button" className="vx-fileinput-clear" aria-label={`Clear ${props.fieldLabel ?? props.label ?? 'files'}`} onClick={() => { setValidationErrors([]); fireChange([]) }}>{'×'}</button> : null}
|
|
162
|
+
{isBusy ? <ActivityIndicator busy className="vx-fileinput-spinner" /> : null}
|
|
163
|
+
{statusType ? (
|
|
164
|
+
props.statusVariant === 'tooltip' && statusMessage
|
|
165
|
+
? <Tooltip trigger={<span className={cx('vx-field-icon', `vx-field-icon--${statusType}`)}>{STATUS_GLYPHS[statusType]}</span>} content={statusMessage} />
|
|
166
|
+
: <span className={cx('vx-field-icon', `vx-field-icon--${statusType}`)}>{STATUS_GLYPHS[statusType]}</span>
|
|
167
|
+
) : null}
|
|
168
|
+
{statusMessage && props.statusVariant !== 'tooltip'
|
|
169
|
+
? <span className={cx('vx-field-status', `vx-field-status--${statusType}`)} role={statusType === 'error' ? 'alert' : 'status'}>{statusMessage}</span>
|
|
170
|
+
: null}
|
|
171
|
+
<input
|
|
172
|
+
ref={(el: any) => { fileInput.current = el; }}
|
|
173
|
+
type="file"
|
|
174
|
+
className="vx-fileinput-nativeinput"
|
|
175
|
+
accept={props.accept}
|
|
176
|
+
multiple={props.isMultiple || undefined}
|
|
177
|
+
disabled={isDisabled || undefined}
|
|
178
|
+
aria-hidden="true"
|
|
179
|
+
tabIndex={-1}
|
|
180
|
+
onChange={handleFileSelect}
|
|
181
|
+
/>
|
|
182
|
+
</>
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
useImperativeHandle(props.ref, () => ({ open: pick, native: fileInput.current }), undefined);
|
|
186
|
+
|
|
187
|
+
<div
|
|
188
|
+
className={cx(
|
|
189
|
+
'vx-fileinput',
|
|
190
|
+
dropzone && 'vx-fileinput--dropzone',
|
|
191
|
+
isDisabled && 'vx-fileinput--disabled',
|
|
192
|
+
isBusy && 'vx-fileinput--loading',
|
|
193
|
+
dragging && 'vx-fileinput--dragging',
|
|
194
|
+
statusType && `vx-fileinput--status-${statusType}`,
|
|
195
|
+
props.size && `vx-fileinput--size-${props.size}`,
|
|
196
|
+
props.className,
|
|
197
|
+
)}
|
|
198
|
+
style={props.style}
|
|
199
|
+
onDragOver={(e: any) => { e.preventDefault(); if (!isDisabled && !isBusy) setDragging(true) }}
|
|
200
|
+
onDragEnter={(e: any) => { e.preventDefault() }}
|
|
201
|
+
onDragLeave={() => setDragging(false)}
|
|
202
|
+
onDrop={handleDrop}
|
|
203
|
+
>
|
|
204
|
+
{itemContent}
|
|
205
|
+
</div>
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* File entry — `compact` (button + list) or `dropzone` mode. Values are
|
|
210
|
+
* `{name, uri, size?, mimeType?}` portable refs. On web the hidden browser
|
|
211
|
+
* input returns the `File` alongside its object URL. Drag/drop targets the
|
|
212
|
+
* control wrapper in both modes.
|
|
213
|
+
*/
|
|
214
|
+
export function FileInput(props: FileInputProps) @{
|
|
215
|
+
const { inField } = useFieldControl(props)
|
|
216
|
+
const cid = useId()
|
|
217
|
+
const control = <FileInputControl {...props} />
|
|
218
|
+
if (inField) return control
|
|
219
|
+
return (
|
|
220
|
+
<FormField
|
|
221
|
+
inputID={props.id ?? cid}
|
|
222
|
+
label={props.label}
|
|
223
|
+
isLabelHidden={props.isLabelHidden}
|
|
224
|
+
description={props.description}
|
|
225
|
+
isDisabled={props.isDisabled}
|
|
226
|
+
disabledMessage={props.disabledMessage}
|
|
227
|
+
isReadOnly={props.isReadOnly}
|
|
228
|
+
isLoading={props.isLoading}
|
|
229
|
+
isRequired={props.isRequired}
|
|
230
|
+
isOptional={props.isOptional}
|
|
231
|
+
size={props.size}
|
|
232
|
+
status={props.status}
|
|
233
|
+
statusVariant={props.statusVariant}
|
|
234
|
+
labelTooltip={props.labelTooltip}
|
|
235
|
+
width={props.width}
|
|
236
|
+
>
|
|
237
|
+
{control}
|
|
238
|
+
</FormField>
|
|
239
|
+
)
|
|
240
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { fileAccepts, validateFiles } from './file-input-utils'
|
|
3
|
+
|
|
4
|
+
describe('file validation', () => {
|
|
5
|
+
it('matches accept tokens', () => {
|
|
6
|
+
expect(fileAccepts({ name: 'a.png', uri: 'x' }, '.png')).toBe(true)
|
|
7
|
+
expect(fileAccepts({ name: 'a.jpg', uri: 'x' }, '.png')).toBe(false)
|
|
8
|
+
expect(fileAccepts({ name: 'a.png', uri: 'x', mimeType: 'image/png' }, 'image/*')).toBe(true)
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it('enforces maxSize and maxFiles', () => {
|
|
12
|
+
const files = [
|
|
13
|
+
{ name: 'a', uri: 'a', size: 10 },
|
|
14
|
+
{ name: 'b', uri: 'b', size: 99999 },
|
|
15
|
+
{ name: 'c', uri: 'c' },
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
const result = validateFiles(files, { maxSize: 100, maxFiles: 1 })
|
|
19
|
+
expect(result.valid.map((file) => file.name)).toEqual(['a'])
|
|
20
|
+
expect(result.errors).toHaveLength(2)
|
|
21
|
+
})
|
|
22
|
+
})
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared formatting and validation helpers for this package's FileInput.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { FileInputFile } from './props'
|
|
6
|
+
|
|
7
|
+
export function cx(...values: Array<string | false | null | undefined>): string {
|
|
8
|
+
return values.filter(Boolean).join(' ')
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function normalizePicked(result: FileInputFile[] | FileInputFile | null): FileInputFile[] {
|
|
12
|
+
if (result == null) {
|
|
13
|
+
return []
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return Array.isArray(result) ? result : [result]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Validation — `accept` tokens (`.ext`, `type/subtype`, `type/*`, `*/*`),
|
|
21
|
+
// `maxSize` bytes, `maxFiles` count. Refs without `size`/`mimeType` skip the
|
|
22
|
+
// checks that need them.
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
export interface FileValidationOptions {
|
|
26
|
+
accept?: string
|
|
27
|
+
maxSize?: number
|
|
28
|
+
maxFiles?: number
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface FileValidationResult {
|
|
32
|
+
valid: FileInputFile[]
|
|
33
|
+
errors: string[]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function fileAccepts(file: FileInputFile, accept: string | undefined): boolean {
|
|
37
|
+
if (!accept) {
|
|
38
|
+
return true
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const tokens = accept
|
|
42
|
+
.split(',')
|
|
43
|
+
.map((t) => t.trim().toLowerCase())
|
|
44
|
+
.filter(Boolean)
|
|
45
|
+
|
|
46
|
+
if (!tokens.length) {
|
|
47
|
+
return true
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const name = file.name.toLowerCase()
|
|
51
|
+
const mime = (file.mimeType ?? '').toLowerCase()
|
|
52
|
+
return tokens.some((token) => {
|
|
53
|
+
if (token === '*/*') {
|
|
54
|
+
return true
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (token.startsWith('.')) {
|
|
58
|
+
return name.endsWith(token)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (token.endsWith('/*')) {
|
|
62
|
+
return mime.startsWith(token.slice(0, -1))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return mime === token
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function formatFileSize(bytes: number): string {
|
|
70
|
+
if (bytes < 1024) {
|
|
71
|
+
return `${bytes} B`
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (bytes < 1024 * 1024) {
|
|
75
|
+
return `${(bytes / 1024).toFixed(1)} KB`
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (bytes < 1024 * 1024 * 1024) {
|
|
79
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function validateFiles(
|
|
86
|
+
files: FileInputFile[],
|
|
87
|
+
opts: FileValidationOptions,
|
|
88
|
+
): FileValidationResult {
|
|
89
|
+
const errors: string[] = []
|
|
90
|
+
const valid: FileInputFile[] = []
|
|
91
|
+
for (const file of files) {
|
|
92
|
+
if (!fileAccepts(file, opts.accept)) {
|
|
93
|
+
errors.push(`${file.name}: file type not accepted`)
|
|
94
|
+
continue
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (opts.maxSize != null && file.size != null && file.size > opts.maxSize) {
|
|
98
|
+
errors.push(`${file.name}: file exceeds ${formatFileSize(opts.maxSize)}`)
|
|
99
|
+
continue
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
valid.push(file)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (opts.maxFiles != null && valid.length > opts.maxFiles) {
|
|
106
|
+
const extra = valid.splice(opts.maxFiles)
|
|
107
|
+
for (const file of extra) {
|
|
108
|
+
errors.push(`${file.name}: exceeds the ${opts.maxFiles}-file limit`)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return { valid, errors }
|
|
113
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Files — AppKit host leaf.
|
|
2
|
+
import type { FileRef } from './types'
|
|
3
|
+
|
|
4
|
+
function openPanel(accept = '*/*', multiple = false): FileRef[] | null {
|
|
5
|
+
const appKit = globalThis as any
|
|
6
|
+
const Panel = appKit.NSOpenPanel
|
|
7
|
+
if (!Panel?.openPanel) {
|
|
8
|
+
throw new Error('AppKit NSOpenPanel is unavailable in this macOS host')
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const panel = Panel.openPanel()
|
|
12
|
+
panel.canChooseFiles = true
|
|
13
|
+
panel.canChooseDirectories = false
|
|
14
|
+
panel.allowsMultipleSelection = multiple
|
|
15
|
+
const extensions = accept
|
|
16
|
+
.split(',')
|
|
17
|
+
.map((value) => value.trim())
|
|
18
|
+
.filter((value) => value.startsWith('.'))
|
|
19
|
+
.map((value) => value.slice(1))
|
|
20
|
+
|
|
21
|
+
if (extensions.length) {
|
|
22
|
+
panel.allowedFileTypes = extensions
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (panel.runModal() !== (appKit.NSModalResponseOK ?? 1)) {
|
|
26
|
+
return null
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const urls: any[] = []
|
|
30
|
+
if (multiple) {
|
|
31
|
+
const selected = panel.URLs
|
|
32
|
+
for (let index = 0; index < Number(selected?.count ?? 0); index++) {
|
|
33
|
+
urls.push(selected.objectAtIndex(index))
|
|
34
|
+
}
|
|
35
|
+
} else if (panel.URL) {
|
|
36
|
+
urls.push(panel.URL)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return urls.map((url) => ({
|
|
40
|
+
name: String(url.lastPathComponent ?? ''),
|
|
41
|
+
uri: String(url.absoluteString ?? url.path ?? ''),
|
|
42
|
+
}))
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const files = {
|
|
46
|
+
async pick(accept = '*/*'): Promise<FileRef | null> {
|
|
47
|
+
return openPanel(accept)?.[0] ?? null
|
|
48
|
+
},
|
|
49
|
+
async pickMultiple(accept = '*/*'): Promise<FileRef[]> {
|
|
50
|
+
return openPanel(accept, true) ?? []
|
|
51
|
+
},
|
|
52
|
+
async readText(_file: FileRef): Promise<string> {
|
|
53
|
+
throw new Error('unsupported: AppKit file access is not wired')
|
|
54
|
+
},
|
|
55
|
+
async writeText(_name: string, _text: string): Promise<FileRef> {
|
|
56
|
+
throw new Error('unsupported: the AppKit host does not provide a save panel')
|
|
57
|
+
},
|
|
58
|
+
release(_file: FileRef): void {},
|
|
59
|
+
}
|
package/src/files.ts
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// Files — native leaf. FileRef wraps an opaque native URI. App-document paths
|
|
2
|
+
// are still used for writes; picked Android SAF references may be content://
|
|
3
|
+
// URIs and must be read through ContentResolver.
|
|
4
|
+
import { openFilePicker } from '@nativescript-community/ui-document-picker'
|
|
5
|
+
import { Application, File, knownFolders, path } from '@nativescript/core'
|
|
6
|
+
import type { FileRef } from './types'
|
|
7
|
+
|
|
8
|
+
const docs = () => knownFolders.documents()
|
|
9
|
+
|
|
10
|
+
function pickerTypes(accept: string): { extensions: string[]; mimeTypes: string[] } {
|
|
11
|
+
const values = accept
|
|
12
|
+
.split(',')
|
|
13
|
+
.map((value) => value.trim())
|
|
14
|
+
.filter(Boolean)
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
extensions: values.filter((value) => value.startsWith('.')).map((value) => value.slice(1)),
|
|
18
|
+
// `*/*` is the default, not a useful iOS UTType MIME value. Leaving it
|
|
19
|
+
// out lets the upstream picker use its public.data fallback.
|
|
20
|
+
mimeTypes: values.filter((value) => value.includes('/') && value !== '*/*'),
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function androidDisplayName(uri: string, nativeUri: any): string | undefined {
|
|
25
|
+
if (!Application.android || !uri.startsWith('content://')) {
|
|
26
|
+
return undefined
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const activity = Application.android.foregroundActivity ?? Application.android.startActivity
|
|
31
|
+
// Android accepts null for projection/filter args; its generated
|
|
32
|
+
// declarations omit that nullable bridge contract.
|
|
33
|
+
const noColumns = null as unknown as string[]
|
|
34
|
+
const noFilter = null as unknown as string
|
|
35
|
+
const cursor = activity
|
|
36
|
+
?.getContentResolver()
|
|
37
|
+
?.query(nativeUri ?? android.net.Uri.parse(uri), noColumns, noFilter, noColumns, noFilter)
|
|
38
|
+
|
|
39
|
+
if (!cursor) {
|
|
40
|
+
return undefined
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
if (!cursor.moveToFirst()) {
|
|
45
|
+
return undefined
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const index = cursor.getColumnIndex('display_name')
|
|
49
|
+
return index >= 0 ? cursor.getString(index) : undefined
|
|
50
|
+
} finally {
|
|
51
|
+
cursor.close()
|
|
52
|
+
}
|
|
53
|
+
} catch {
|
|
54
|
+
return undefined
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function fallbackName(uri: string): string {
|
|
59
|
+
const segment = uri.split('/').pop() ?? ''
|
|
60
|
+
try {
|
|
61
|
+
return decodeURIComponent(segment) || 'document'
|
|
62
|
+
} catch {
|
|
63
|
+
return segment || 'document'
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function readContentUri(uri: string): Promise<string> {
|
|
68
|
+
const activity = Application.android?.foregroundActivity ?? Application.android?.startActivity
|
|
69
|
+
const input = activity?.getContentResolver()?.openInputStream(android.net.Uri.parse(uri))
|
|
70
|
+
|
|
71
|
+
if (!input) {
|
|
72
|
+
throw new Error(`Unable to open picked file: ${uri}`)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const reader = new java.io.BufferedReader(new java.io.InputStreamReader(input))
|
|
76
|
+
const lines: string[] = []
|
|
77
|
+
try {
|
|
78
|
+
let line: string | null
|
|
79
|
+
while ((line = reader.readLine()) !== null) {
|
|
80
|
+
lines.push(line)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return lines.join('\n')
|
|
84
|
+
} finally {
|
|
85
|
+
reader.close()
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function pickFiles(
|
|
90
|
+
accept: string,
|
|
91
|
+
multiple: boolean,
|
|
92
|
+
opts?: { startingFolder?: string },
|
|
93
|
+
): Promise<FileRef[]> {
|
|
94
|
+
const { extensions, mimeTypes } = pickerTypes(accept)
|
|
95
|
+
const result = await openFilePicker({
|
|
96
|
+
extensions,
|
|
97
|
+
mimeTypes,
|
|
98
|
+
multipleSelection: multiple,
|
|
99
|
+
permissions: { read: true, persistable: true },
|
|
100
|
+
startingFolder: opts?.startingFolder,
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
return (result.files ?? []).map((uri, index) => ({
|
|
104
|
+
name: androidDisplayName(uri, index === 0 ? result.android : undefined) ?? fallbackName(uri),
|
|
105
|
+
uri,
|
|
106
|
+
}))
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export const files = {
|
|
110
|
+
/** Opens the platform document picker and returns selected opaque URIs. */
|
|
111
|
+
async pickMultiple(accept = '*/*', opts?: { startingFolder?: string }): Promise<FileRef[]> {
|
|
112
|
+
return pickFiles(accept, true, opts)
|
|
113
|
+
},
|
|
114
|
+
/** Opens the platform document picker and returns its first opaque URI. */
|
|
115
|
+
async pick(accept = '*/*', opts?: { startingFolder?: string }): Promise<FileRef | null> {
|
|
116
|
+
return (await pickFiles(accept, false, opts))[0] ?? null
|
|
117
|
+
},
|
|
118
|
+
async readText(ref: FileRef): Promise<string> {
|
|
119
|
+
if (ref.uri.startsWith('content://')) {
|
|
120
|
+
return readContentUri(ref.uri)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return File.fromPath(ref.uri).readText()
|
|
124
|
+
},
|
|
125
|
+
async writeText(name: string, text: string): Promise<FileRef> {
|
|
126
|
+
const p = path.join(docs().path, name)
|
|
127
|
+
const f = File.fromPath(p)
|
|
128
|
+
f.writeTextSync(text)
|
|
129
|
+
return { name, uri: p }
|
|
130
|
+
},
|
|
131
|
+
release(ref: FileRef): void {
|
|
132
|
+
const cachePrefix = knownFolders.temp().path + '/'
|
|
133
|
+
if (!ref.uri.startsWith(cachePrefix)) {
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
File.fromPath(ref.uri).removeSync()
|
|
139
|
+
} catch {}
|
|
140
|
+
},
|
|
141
|
+
}
|
package/src/files.web.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Files — web leaf. Desktop webview hosts own real pickers and file:// refs;
|
|
2
|
+
// outside a host this uses opaque object URLs and browser downloads.
|
|
3
|
+
import { desktopHost } from '@octane-xplat/platform/host/web'
|
|
4
|
+
import type { FileRef } from './types'
|
|
5
|
+
|
|
6
|
+
function chooseFiles(accept: string, multiple: boolean): Promise<FileRef[]> {
|
|
7
|
+
return new Promise((resolve) => {
|
|
8
|
+
const input = document.createElement('input')
|
|
9
|
+
input.type = 'file'
|
|
10
|
+
input.accept = accept
|
|
11
|
+
input.multiple = multiple
|
|
12
|
+
input.onchange = () => {
|
|
13
|
+
const refs: FileRef[] = []
|
|
14
|
+
for (const file of Array.from(input.files ?? [])) {
|
|
15
|
+
refs.push({ name: file.name, uri: URL.createObjectURL(file) })
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
resolve(refs)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
input.oncancel = () => resolve([])
|
|
22
|
+
input.click()
|
|
23
|
+
})
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const files = {
|
|
27
|
+
async pickMultiple(accept = '*/*', _opts?: { startingFolder?: string }): Promise<FileRef[]> {
|
|
28
|
+
return chooseFiles(accept, true)
|
|
29
|
+
},
|
|
30
|
+
async pick(accept = '*/*', opts?: { startingFolder?: string }): Promise<FileRef | null> {
|
|
31
|
+
const host = desktopHost()
|
|
32
|
+
if (host && (await host.supports('files', 'pick'))) {
|
|
33
|
+
return host.files.pick(accept, { startingFolder: opts?.startingFolder })
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return (await chooseFiles(accept, false))[0] ?? null
|
|
37
|
+
},
|
|
38
|
+
async readText(ref: FileRef): Promise<string> {
|
|
39
|
+
const host = desktopHost()
|
|
40
|
+
if (host && (await host.supports('files', 'readText'))) {
|
|
41
|
+
const text = await host.files.readText(ref.uri)
|
|
42
|
+
if (text === null) {
|
|
43
|
+
throw new Error(`Unable to read host file: ${ref.uri}`)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return text
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return (await fetch(ref.uri)).text()
|
|
50
|
+
},
|
|
51
|
+
/** Desktop hosts write the selected path; plain web starts a download. */
|
|
52
|
+
async writeText(name: string, text: string): Promise<FileRef> {
|
|
53
|
+
const host = desktopHost()
|
|
54
|
+
if (host && (await host.supports('files', 'writeText'))) {
|
|
55
|
+
const file = await host.files.writeText(name, text)
|
|
56
|
+
if (!file) {
|
|
57
|
+
throw new Error('Host file save was cancelled')
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return file
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const uri = URL.createObjectURL(new Blob([text], { type: 'text/plain' }))
|
|
64
|
+
const a = document.createElement('a')
|
|
65
|
+
a.href = uri
|
|
66
|
+
a.download = name
|
|
67
|
+
a.click()
|
|
68
|
+
return { name, uri }
|
|
69
|
+
},
|
|
70
|
+
release(ref: FileRef): void {
|
|
71
|
+
if (ref.uri.startsWith('blob:')) {
|
|
72
|
+
URL.revokeObjectURL(ref.uri)
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export * from './types'
|
|
2
|
+
export { files } from './files.macos'
|
|
3
|
+
export { FileInput } from './FileInput.macos.tsrx'
|
|
4
|
+
export type {
|
|
5
|
+
FileInputFile,
|
|
6
|
+
FileInputHandle,
|
|
7
|
+
FileInputPick,
|
|
8
|
+
FileInputProps,
|
|
9
|
+
FileInputStatus,
|
|
10
|
+
FileInputStatusType,
|
|
11
|
+
} from './props'
|
package/src/index.ts
ADDED