@gjsify/adwaita-web 0.60.0 → 0.61.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/README.md +11 -0
- package/lib/types/elements/gtk-drop-down.d.ts +4 -0
- package/lib/types/elements/gtk-label.d.ts +4 -0
- package/lib/types/elements/gtk-text-view.d.ts +15 -0
- package/lib/types/file-chooser-host.d.ts +55 -0
- package/lib/types/file-chooser.spec.d.ts +1 -0
- package/lib/types/gtk-platform.spec.d.ts +1 -0
- package/lib/types/namespace/adw.d.ts +1 -0
- package/lib/types/namespace/gdk.d.ts +2 -0
- package/lib/types/namespace/gio.d.ts +3 -0
- package/lib/types/namespace/glib.d.ts +1 -0
- package/lib/types/namespace/gtk-enums.d.ts +22 -0
- package/lib/types/namespace/gtk-platform.d.ts +13 -0
- package/lib/types/namespace/gtk.d.ts +4 -1
- package/lib/types/platform-host.d.ts +2 -0
- package/lib/types/style-manager.d.ts +7 -0
- package/package.json +18 -9
- package/src/capabilities.mjs +1 -4
- package/src/construct-vectors.spec.ts +10 -1
- package/src/elements/gtk-drop-down.ts +10 -0
- package/src/elements/gtk-label.ts +10 -0
- package/src/elements/gtk-text-view.ts +40 -0
- package/src/file-chooser-host.ts +421 -0
- package/src/file-chooser.spec.ts +376 -0
- package/src/gobject-door.ts +7 -1
- package/src/gobject-elements.ts +4 -0
- package/src/gtk-drop-down.spec.ts +11 -0
- package/src/gtk-label.spec.ts +12 -0
- package/src/gtk-platform.spec.ts +19 -0
- package/src/gtk-text-view.spec.ts +22 -0
- package/src/namespace/adw.ts +1 -0
- package/src/namespace/gdk.ts +18 -1
- package/src/namespace/gio.ts +20 -2
- package/src/namespace/glib.ts +6 -1
- package/src/namespace/gtk-enums.ts +7 -2
- package/src/namespace/gtk-platform.ts +16 -0
- package/src/namespace/gtk.ts +12 -1
- package/src/platform-host.ts +88 -0
- package/src/shared-tree-builder.ts +40 -0
- package/src/shared-trees.spec.ts +36 -0
- package/src/style-manager.ts +10 -1
- package/src/test.browser.mts +4 -0
- package/src/widget-members.spec.ts +12 -0
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
// How the web door answers `Gtk.FileDialog` (ADR 0100 § 4, § 4a, § 5). The subset itself — argument
|
|
2
|
+
// checks, the async/finish pairs, `Gio.File`, the stream — is `@gjsify/adwaita-core`'s; this file is
|
|
3
|
+
// the driver below it.
|
|
4
|
+
//
|
|
5
|
+
// THE DOOR CHOOSES BY FEATURE, NEVER BY BROWSER. No line here reads the user agent:
|
|
6
|
+
// - `'showSaveFilePicker' in window`: the system pickers of File System Access, over the user's
|
|
7
|
+
// real files. The filters become `types[].accept`.
|
|
8
|
+
// - otherwise: the Origin Private File System, in a dialog drawn from Adwaita elements
|
|
9
|
+
// ({@link presentOpfsDialog}). A file picked through `<input type=file>` from that dialog is
|
|
10
|
+
// COPIED into OPFS, so the next save writes the copy and not a download.
|
|
11
|
+
// - `createWritable` on the handle: the write streams on the main thread; where it is missing
|
|
12
|
+
// (Safari before 26), a dedicated worker writes with `createSyncAccessHandle`.
|
|
13
|
+
//
|
|
14
|
+
// A download is never a stand-in for save; nothing here creates one.
|
|
15
|
+
//
|
|
16
|
+
// THE DRAWN DIALOG is a clean-room re-implementation: its STRUCTURE follows the current GNOME file
|
|
17
|
+
// chooser (Nautilus' file-chooser mode — an OverlaySplitView with a places sidebar, the file list,
|
|
18
|
+
// and a bottom bar with the filter drop-down, the name entry and the accept button), and no code,
|
|
19
|
+
// template or string is taken from Nautilus (GPL-3.0-or-later). It shows only what OPFS has.
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
dialogFailedError,
|
|
23
|
+
dismissedError,
|
|
24
|
+
fileFilterAccepts,
|
|
25
|
+
registerFileChooserDriver,
|
|
26
|
+
type FileChooserDriver,
|
|
27
|
+
type FileChooserHandle,
|
|
28
|
+
type FileChooserRequest,
|
|
29
|
+
type FileChooserWriter,
|
|
30
|
+
type FileFilterSpec,
|
|
31
|
+
} from '@gjsify/adwaita-core';
|
|
32
|
+
|
|
33
|
+
import './elements/adw-dialog.js';
|
|
34
|
+
import './elements/adw-overlay-split-view.js';
|
|
35
|
+
import './elements/adw-toolbar-view.js';
|
|
36
|
+
import './elements/gtk-button.js';
|
|
37
|
+
import './elements/gtk-drop-down.js';
|
|
38
|
+
import './elements/gtk-entry.js';
|
|
39
|
+
import './elements/gtk-list-box.js';
|
|
40
|
+
import './elements/gtk-list-box-row.js';
|
|
41
|
+
import type { AdwDialog } from './elements/adw-dialog.js';
|
|
42
|
+
import type { GtkDropDown } from './elements/gtk-drop-down.js';
|
|
43
|
+
import type { GtkEntry } from './elements/gtk-entry.js';
|
|
44
|
+
import type { GtkListBoxRow } from './elements/gtk-list-box-row.js';
|
|
45
|
+
|
|
46
|
+
/** What the drawn dialog hands back: an OPFS entry by name, or a file the `<input>` picked. */
|
|
47
|
+
export type OpfsChoice = { readonly name: string } | { readonly upload: File };
|
|
48
|
+
|
|
49
|
+
/** File System Access `showOpenFilePicker`/`showSaveFilePicker` options, as far as the door fills them. */
|
|
50
|
+
export interface FilePickerOptions {
|
|
51
|
+
types?: { description: string; accept: Record<string, string[]> }[];
|
|
52
|
+
excludeAcceptAllOption?: boolean;
|
|
53
|
+
suggestedName?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The platform the driver reads. {@link browserFileChooserEnv} is the real one; a spec passes fakes. */
|
|
57
|
+
export interface WebFileChooserEnv {
|
|
58
|
+
showOpenFilePicker?: (options: FilePickerOptions) => Promise<FileSystemFileHandle[]>;
|
|
59
|
+
showSaveFilePicker?: (options: FilePickerOptions) => Promise<FileSystemFileHandle>;
|
|
60
|
+
/** Transient user activation; an environment that cannot say answers `true`. */
|
|
61
|
+
isActive(): boolean;
|
|
62
|
+
opfsRoot(): Promise<FileSystemDirectoryHandle>;
|
|
63
|
+
/** The drawn dialog over `root`; `null` when the user closed it. */
|
|
64
|
+
choose(
|
|
65
|
+
kind: 'open' | 'save',
|
|
66
|
+
request: FileChooserRequest,
|
|
67
|
+
root: FileSystemDirectoryHandle,
|
|
68
|
+
): Promise<OpfsChoice | null>;
|
|
69
|
+
/** Whether `handle` writes through `createWritable`; the worker writes otherwise. */
|
|
70
|
+
canStreamWrite(handle: FileSystemFileHandle): boolean;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// --- Filters ------------------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
/** The extensions a filter names, with the dot, as `accept` and `types[].accept` take them. */
|
|
76
|
+
function extensionsOf(spec: FileFilterSpec): string[] {
|
|
77
|
+
return [...spec.patterns, ...spec.suffixes].map((ext) => `.${ext}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The File System Access options for `request`. An extension-only filter needs a MIME key, and
|
|
82
|
+
* `application/octet-stream` is the one that claims nothing about the content. A filter that
|
|
83
|
+
* accepts everything is the picker's own accept-all option, so it is not a type.
|
|
84
|
+
*/
|
|
85
|
+
export function filePickerOptions(request: FileChooserRequest, kind: 'open' | 'save'): FilePickerOptions {
|
|
86
|
+
const types: NonNullable<FilePickerOptions['types']> = [];
|
|
87
|
+
for (const spec of request.filters) {
|
|
88
|
+
if (spec.acceptAll) continue;
|
|
89
|
+
const accept: Record<string, string[]> = {};
|
|
90
|
+
for (const mime of spec.mimeTypes) accept[mime] = [];
|
|
91
|
+
const extensions = extensionsOf(spec);
|
|
92
|
+
if (extensions.length > 0) accept[spec.mimeTypes[0] ?? 'application/octet-stream'] = extensions;
|
|
93
|
+
types.push({ description: spec.name ?? '', accept });
|
|
94
|
+
}
|
|
95
|
+
const options: FilePickerOptions = {};
|
|
96
|
+
if (types.length > 0) {
|
|
97
|
+
options.types = types;
|
|
98
|
+
options.excludeAcceptAllOption = !request.filters.some((spec) => spec.acceptAll);
|
|
99
|
+
}
|
|
100
|
+
if (kind === 'save' && request.initialName !== null) options.suggestedName = request.initialName;
|
|
101
|
+
return options;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** `<input type=file accept>` for one filter: extensions and MIME types; `null` accepts every file. */
|
|
105
|
+
export function inputAccept(spec: FileFilterSpec | null): string | null {
|
|
106
|
+
if (!spec || spec.acceptAll) return null;
|
|
107
|
+
const accept = [...extensionsOf(spec), ...spec.mimeTypes];
|
|
108
|
+
return accept.length > 0 ? accept.join(',') : null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// --- Handles and writers ------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
// Opens, truncates, writes and flushes in one message: `createSyncAccessHandle` exists in a
|
|
114
|
+
// dedicated worker only, and only on OPFS files, which is exactly the handle this path gets.
|
|
115
|
+
const WORKER_SOURCE = `onmessage = async (event) => {
|
|
116
|
+
const { handle, data } = event.data;
|
|
117
|
+
let access = null;
|
|
118
|
+
try {
|
|
119
|
+
access = await handle.createSyncAccessHandle();
|
|
120
|
+
access.truncate(0);
|
|
121
|
+
access.write(data, { at: 0 });
|
|
122
|
+
access.flush();
|
|
123
|
+
postMessage({ ok: true });
|
|
124
|
+
} catch (error) {
|
|
125
|
+
postMessage({ ok: false, message: String((error && error.name) || 'Error') + ': ' + String(error && error.message) });
|
|
126
|
+
} finally {
|
|
127
|
+
if (access) access.close();
|
|
128
|
+
}
|
|
129
|
+
};`;
|
|
130
|
+
|
|
131
|
+
function writeInWorker(handle: FileSystemFileHandle, data: Uint8Array): Promise<void> {
|
|
132
|
+
const url = URL.createObjectURL(new Blob([WORKER_SOURCE], { type: 'text/javascript' }));
|
|
133
|
+
const worker = new Worker(url);
|
|
134
|
+
return new Promise<void>((resolve, reject) => {
|
|
135
|
+
worker.onmessage = (event: MessageEvent<{ ok: boolean; message?: string }>) => {
|
|
136
|
+
if (event.data.ok) resolve();
|
|
137
|
+
else reject(new Error(event.data.message));
|
|
138
|
+
};
|
|
139
|
+
worker.onerror = (event) => reject(new Error(event.message || 'the write worker failed'));
|
|
140
|
+
worker.postMessage({ handle, data });
|
|
141
|
+
}).finally(() => {
|
|
142
|
+
worker.terminate();
|
|
143
|
+
URL.revokeObjectURL(url);
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function concat(chunks: readonly Uint8Array[]): Uint8Array {
|
|
148
|
+
const out = new Uint8Array(chunks.reduce((n, c) => n + c.length, 0));
|
|
149
|
+
let at = 0;
|
|
150
|
+
for (const chunk of chunks) {
|
|
151
|
+
out.set(chunk, at);
|
|
152
|
+
at += chunk.length;
|
|
153
|
+
}
|
|
154
|
+
return out;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function openWriter(handle: FileSystemFileHandle, env: WebFileChooserEnv): Promise<FileChooserWriter> {
|
|
158
|
+
if (env.canStreamWrite(handle)) {
|
|
159
|
+
// `keepExistingData` defaults to false: the stream starts empty, and `close()` commits.
|
|
160
|
+
const stream = await handle.createWritable();
|
|
161
|
+
return { write: (chunk) => stream.write(chunk as Uint8Array<ArrayBuffer>), close: () => stream.close() };
|
|
162
|
+
}
|
|
163
|
+
const chunks: Uint8Array[] = [];
|
|
164
|
+
return {
|
|
165
|
+
write: async (chunk) => void chunks.push(chunk.slice()),
|
|
166
|
+
close: () => writeInWorker(handle, concat(chunks)),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function handleFor(handle: FileSystemFileHandle, env: WebFileChooserEnv): FileChooserHandle {
|
|
171
|
+
return {
|
|
172
|
+
basename: handle.name,
|
|
173
|
+
read: async () => new Uint8Array(await (await handle.getFile()).arrayBuffer()),
|
|
174
|
+
openWriter: () => openWriter(handle, env),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** `name`, or `stem (2).ext`, `stem (3).ext` … — whichever `root` does not hold yet. */
|
|
179
|
+
async function freeName(root: FileSystemDirectoryHandle, name: string): Promise<string> {
|
|
180
|
+
const dot = name.lastIndexOf('.');
|
|
181
|
+
const stem = dot > 0 ? name.slice(0, dot) : name;
|
|
182
|
+
const ext = dot > 0 ? name.slice(dot) : '';
|
|
183
|
+
for (let n = 1; ; n++) {
|
|
184
|
+
const candidate = n === 1 ? name : `${stem} (${n})${ext}`;
|
|
185
|
+
try {
|
|
186
|
+
await root.getFileHandle(candidate);
|
|
187
|
+
} catch {
|
|
188
|
+
return candidate;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Copies a picked `File` into OPFS: the copy is what a later save writes, never an existing file. */
|
|
194
|
+
async function copyIntoOpfs(root: FileSystemDirectoryHandle, file: File, env: WebFileChooserEnv) {
|
|
195
|
+
const target = await root.getFileHandle(await freeName(root, file.name), { create: true });
|
|
196
|
+
const writer = await openWriter(target, env);
|
|
197
|
+
await writer.write(new Uint8Array(await file.arrayBuffer()));
|
|
198
|
+
await writer.close();
|
|
199
|
+
return target;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// --- The driver ---------------------------------------------------------------------------------
|
|
203
|
+
|
|
204
|
+
function isAbort(error: unknown): boolean {
|
|
205
|
+
return (error as { name?: unknown } | null)?.name === 'AbortError';
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** The `Gtk.DialogError` a platform rejection becomes: `AbortError` is the user closing the picker. */
|
|
209
|
+
function dialogRejection(error: unknown) {
|
|
210
|
+
if (isAbort(error)) return dismissedError();
|
|
211
|
+
const e = error as { name?: string; message?: string } | null;
|
|
212
|
+
if (e && typeof e.message === 'string') return dialogFailedError(`${e.name ?? 'Error'}: ${e.message}`);
|
|
213
|
+
return dialogFailedError(String(error));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function createWebFileChooserDriver(env: WebFileChooserEnv): FileChooserDriver {
|
|
217
|
+
const run = async (kind: 'open' | 'save', request: FileChooserRequest): Promise<FileChooserHandle> => {
|
|
218
|
+
if (!env.isActive()) {
|
|
219
|
+
throw dialogFailedError(`Gtk.FileDialog.${kind}() needs a user activation (a click or a key press)`);
|
|
220
|
+
}
|
|
221
|
+
try {
|
|
222
|
+
if (env.showSaveFilePicker && env.showOpenFilePicker) {
|
|
223
|
+
const options = filePickerOptions(request, kind);
|
|
224
|
+
const handle =
|
|
225
|
+
kind === 'open'
|
|
226
|
+
? (await env.showOpenFilePicker(options))[0]
|
|
227
|
+
: await env.showSaveFilePicker(options);
|
|
228
|
+
if (!handle) throw dismissedError();
|
|
229
|
+
return handleFor(handle, env);
|
|
230
|
+
}
|
|
231
|
+
const root = await env.opfsRoot();
|
|
232
|
+
const choice = await env.choose(kind, request, root);
|
|
233
|
+
if (choice === null) throw dismissedError();
|
|
234
|
+
if ('upload' in choice) return handleFor(await copyIntoOpfs(root, choice.upload, env), env);
|
|
235
|
+
return handleFor(await root.getFileHandle(choice.name, { create: kind === 'save' }), env);
|
|
236
|
+
} catch (error) {
|
|
237
|
+
throw dialogRejection(error);
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
return { open: (request) => run('open', request), save: (request) => run('save', request) };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// --- The drawn dialog ---------------------------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
function el<K extends string>(tag: K, attrs: Record<string, string> = {}, text?: string): HTMLElement {
|
|
246
|
+
const node = document.createElement(tag);
|
|
247
|
+
for (const [name, value] of Object.entries(attrs)) node.setAttribute(name, value);
|
|
248
|
+
if (text !== undefined) node.textContent = text;
|
|
249
|
+
return node;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function sameFilter(a: FileFilterSpec, b: FileFilterSpec): boolean {
|
|
253
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Lets the user pick an OPFS file (`open`) or name one (`save`). Resolves the choice, or `null`
|
|
258
|
+
* when the dialog closes without one. On `open`, "Upload…" picks a file through `<input type=file>`;
|
|
259
|
+
* its `cancel` event dismisses the whole open, as ADR 0100 § 5 maps it.
|
|
260
|
+
*
|
|
261
|
+
* The dialog sets `data-ready` once the list is filled, which is what a spec waits for.
|
|
262
|
+
*/
|
|
263
|
+
export function presentOpfsDialog(
|
|
264
|
+
kind: 'open' | 'save',
|
|
265
|
+
request: FileChooserRequest,
|
|
266
|
+
root: FileSystemDirectoryHandle,
|
|
267
|
+
): Promise<OpfsChoice | null> {
|
|
268
|
+
const filters = request.filters;
|
|
269
|
+
const initial = request.defaultFilter ? filters.findIndex((f) => sameFilter(f, request.defaultFilter!)) : 0;
|
|
270
|
+
let current: FileFilterSpec | null = filters[Math.max(initial, 0)] ?? request.defaultFilter;
|
|
271
|
+
|
|
272
|
+
const dialog = el('adw-dialog', {
|
|
273
|
+
title: request.title ?? (kind === 'open' ? 'Open File' : 'Save File'),
|
|
274
|
+
'content-width': '720',
|
|
275
|
+
'content-height': '480',
|
|
276
|
+
}) as AdwDialog;
|
|
277
|
+
|
|
278
|
+
const split = el('adw-overlay-split-view', { breakpoint: 'max-width: 560px' });
|
|
279
|
+
const sidebar = el('div', { slot: 'sidebar' });
|
|
280
|
+
const places = el('gtk-list-box', { class: 'navigation-sidebar', 'selection-mode': 'browse' });
|
|
281
|
+
places.append(el('gtk-list-box-row', {}, 'Origin Storage'));
|
|
282
|
+
sidebar.append(places);
|
|
283
|
+
|
|
284
|
+
const view = el('adw-toolbar-view');
|
|
285
|
+
const list = el('gtk-list-box', { 'selection-mode': 'single', 'activate-on-single-click': 'false' });
|
|
286
|
+
const empty = el('p', { class: 'dimmed', hidden: '' }, 'No files');
|
|
287
|
+
const bottom = el('div', { slot: 'bottom', class: 'toolbar' });
|
|
288
|
+
|
|
289
|
+
let dropDown: GtkDropDown | null = null;
|
|
290
|
+
if (filters.length > 0) {
|
|
291
|
+
dropDown = el('gtk-drop-down') as GtkDropDown;
|
|
292
|
+
dropDown.model = filters.map((f, i) => f.name ?? `Filter ${i + 1}`);
|
|
293
|
+
dropDown.selected = Math.max(initial, 0);
|
|
294
|
+
bottom.append(dropDown);
|
|
295
|
+
}
|
|
296
|
+
let entry: GtkEntry | null = null;
|
|
297
|
+
if (kind === 'save') {
|
|
298
|
+
entry = el('gtk-entry', { 'placeholder-text': 'Name' }) as GtkEntry;
|
|
299
|
+
entry.style.flex = '1';
|
|
300
|
+
entry.value = request.initialName ?? '';
|
|
301
|
+
bottom.append(entry);
|
|
302
|
+
}
|
|
303
|
+
let upload: HTMLElement | null = null;
|
|
304
|
+
if (kind === 'open') {
|
|
305
|
+
upload = el('gtk-button', { label: 'Upload…' });
|
|
306
|
+
bottom.append(upload);
|
|
307
|
+
}
|
|
308
|
+
const spacer = el('div');
|
|
309
|
+
spacer.style.flex = '1';
|
|
310
|
+
const accept = el('gtk-button', { label: kind === 'open' ? 'Open' : 'Save', suggested: '' });
|
|
311
|
+
bottom.append(spacer, accept);
|
|
312
|
+
|
|
313
|
+
view.append(list, empty, bottom);
|
|
314
|
+
split.append(sidebar, view);
|
|
315
|
+
dialog.append(split);
|
|
316
|
+
|
|
317
|
+
let selected: string | null = null;
|
|
318
|
+
|
|
319
|
+
const refill = async () => {
|
|
320
|
+
dialog.removeAttribute('data-ready');
|
|
321
|
+
const names: string[] = [];
|
|
322
|
+
for await (const [name, entryHandle] of root.entries()) {
|
|
323
|
+
if (entryHandle.kind === 'file' && (!current || fileFilterAccepts(current, name))) names.push(name);
|
|
324
|
+
}
|
|
325
|
+
names.sort((a, b) => a.localeCompare(b));
|
|
326
|
+
list.replaceChildren(
|
|
327
|
+
...names.map((name) => {
|
|
328
|
+
const row = el('gtk-list-box-row', { 'data-name': name }, name);
|
|
329
|
+
return row;
|
|
330
|
+
}),
|
|
331
|
+
);
|
|
332
|
+
selected = null;
|
|
333
|
+
empty.hidden = names.length > 0;
|
|
334
|
+
dialog.setAttribute('data-ready', '');
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
return new Promise<OpfsChoice | null>((resolve) => {
|
|
338
|
+
let settled = false;
|
|
339
|
+
const finish = (choice: OpfsChoice | null) => {
|
|
340
|
+
if (settled) return;
|
|
341
|
+
settled = true;
|
|
342
|
+
resolve(choice);
|
|
343
|
+
dialog.forceClose();
|
|
344
|
+
dialog.remove();
|
|
345
|
+
};
|
|
346
|
+
const acceptCurrent = () => {
|
|
347
|
+
if (kind === 'open') {
|
|
348
|
+
if (selected !== null) finish({ name: selected });
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
const name = entry!.value.trim();
|
|
352
|
+
if (name !== '' && !name.includes('/') && name !== '.' && name !== '..') finish({ name });
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
list.addEventListener('row-selected', (event) => {
|
|
356
|
+
const row = (event as CustomEvent<{ row: GtkListBoxRow | null }>).detail.row;
|
|
357
|
+
selected = row?.dataset.name ?? null;
|
|
358
|
+
if (entry && selected !== null) entry.value = selected;
|
|
359
|
+
});
|
|
360
|
+
list.addEventListener('row-activated', (event) => {
|
|
361
|
+
const row = (event as CustomEvent<{ row: GtkListBoxRow }>).detail.row;
|
|
362
|
+
selected = row.dataset.name ?? null;
|
|
363
|
+
if (entry && selected !== null) entry.value = selected;
|
|
364
|
+
acceptCurrent();
|
|
365
|
+
});
|
|
366
|
+
entry?.addEventListener('activate', acceptCurrent);
|
|
367
|
+
accept.addEventListener('click', acceptCurrent);
|
|
368
|
+
dropDown?.addEventListener('notify::selected', (event) => {
|
|
369
|
+
current = filters[(event as CustomEvent<{ selected: number }>).detail.selected] ?? null;
|
|
370
|
+
void refill();
|
|
371
|
+
});
|
|
372
|
+
upload?.addEventListener('click', () => {
|
|
373
|
+
const input = document.createElement('input');
|
|
374
|
+
input.type = 'file';
|
|
375
|
+
const acceptAttr = inputAccept(current);
|
|
376
|
+
if (acceptAttr !== null) input.accept = acceptAttr;
|
|
377
|
+
input.addEventListener('change', () => {
|
|
378
|
+
const file = input.files?.[0];
|
|
379
|
+
if (file) finish({ upload: file });
|
|
380
|
+
});
|
|
381
|
+
input.addEventListener('cancel', () => finish(null));
|
|
382
|
+
input.click();
|
|
383
|
+
});
|
|
384
|
+
dialog.addEventListener('closed', () => finish(null));
|
|
385
|
+
|
|
386
|
+
dialog.present();
|
|
387
|
+
void refill();
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// --- The real environment -----------------------------------------------------------------------
|
|
392
|
+
|
|
393
|
+
type PickerWindow = {
|
|
394
|
+
showOpenFilePicker?: (options: FilePickerOptions) => Promise<FileSystemFileHandle[]>;
|
|
395
|
+
showSaveFilePicker?: (options: FilePickerOptions) => Promise<FileSystemFileHandle>;
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
export function browserFileChooserEnv(): WebFileChooserEnv {
|
|
399
|
+
const win = (typeof window === 'undefined' ? {} : window) as PickerWindow;
|
|
400
|
+
const pickers = 'showSaveFilePicker' in win && 'showOpenFilePicker' in win;
|
|
401
|
+
return {
|
|
402
|
+
showOpenFilePicker: pickers ? (options) => win.showOpenFilePicker!(options) : undefined,
|
|
403
|
+
showSaveFilePicker: pickers ? (options) => win.showSaveFilePicker!(options) : undefined,
|
|
404
|
+
isActive: () =>
|
|
405
|
+
typeof navigator !== 'undefined' && navigator.userActivation ? navigator.userActivation.isActive : true,
|
|
406
|
+
opfsRoot: async () => {
|
|
407
|
+
if (typeof navigator === 'undefined' || typeof navigator.storage?.getDirectory !== 'function') {
|
|
408
|
+
throw new Error(
|
|
409
|
+
'this browser has neither the File System Access pickers nor an Origin Private File System',
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
return navigator.storage.getDirectory();
|
|
413
|
+
},
|
|
414
|
+
choose: presentOpfsDialog,
|
|
415
|
+
canStreamWrite: (handle) => typeof (handle as { createWritable?: unknown }).createWritable === 'function',
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
export const webFileChooserDriver = createWebFileChooserDriver(browserFileChooserEnv());
|
|
420
|
+
|
|
421
|
+
registerFileChooserDriver(webFileChooserDriver);
|