The fallback download shows a one-time hint about the browser's ask-where-to-save setting, keeps its anchor in the DOM and the blob URL alive past the click (Gecko). Failed saves and exports now report the underlying error, with a clear message when a script blocker prevented a lazy chunk from loading. A full or blocked localStorage no longer fails the save when recording the recent-files entry.
150 lines
6.0 KiB
TypeScript
150 lines
6.0 KiB
TypeScript
// File System Access API helpers for saving/opening .odt files. Falls back to a
|
|
// plain browser download / no-op where the API is unavailable (Firefox/Safari).
|
|
|
|
import { t } from '../i18n/i18n.svelte';
|
|
|
|
// Shown once per browser, at the first save that goes the download route — the
|
|
// moment the missing save dialog is actually felt.
|
|
const HINT_KEY = 'edentext-download-hint';
|
|
|
|
const ODT_MIME = 'application/vnd.oasis.opendocument.text';
|
|
const OTT_MIME = 'application/vnd.oasis.opendocument.text-template';
|
|
const DOCX_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
|
|
|
|
const PICKER_TYPES = [
|
|
{ description: 'OpenDocument Text', accept: { [ODT_MIME]: ['.odt'] } },
|
|
];
|
|
|
|
const DOCX_PICKER_TYPES = [
|
|
{ description: 'Word Document', accept: { [DOCX_MIME]: ['.docx'] } },
|
|
];
|
|
|
|
const DOTX_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.template';
|
|
|
|
// Both template formats in one picker: the chosen extension decides which is written.
|
|
const TEMPLATE_PICKER_TYPES = [
|
|
{ description: 'OpenDocument Text Template', accept: { [OTT_MIME]: ['.ott'] } },
|
|
{ description: 'Word Template', accept: { [DOTX_MIME]: ['.dotx'] } },
|
|
];
|
|
|
|
// The open picker also accepts templates (read-only; saving stays .odt-only).
|
|
const OPEN_PICKER_TYPES = [
|
|
{ description: 'OpenDocument Text', accept: { [ODT_MIME]: ['.odt'], [OTT_MIME]: ['.ott'] } },
|
|
{ description: 'Word Document', accept: { [DOCX_MIME]: ['.docx'], [DOTX_MIME]: ['.dotx'] } },
|
|
];
|
|
|
|
// showSaveFilePicker/showOpenFilePicker are not in lib.dom yet; reach them via casts.
|
|
type WinFs = Window & {
|
|
showSaveFilePicker?: (opts: unknown) => Promise<FileSystemFileHandle>;
|
|
showOpenFilePicker?: (opts: unknown) => Promise<FileSystemFileHandle[]>;
|
|
};
|
|
|
|
export function supportsFsAccess(): boolean {
|
|
return typeof (window as WinFs).showSaveFilePicker === 'function';
|
|
}
|
|
|
|
// Where the browser saves is the browser's business (its download folder, or its own
|
|
// dialog where that is switched on). Gecko needs the anchor in the document and the
|
|
// blob URL alive past the click.
|
|
function download(bytes: Uint8Array, name: string, mime: string = ODT_MIME): void {
|
|
// Before the click: the browser's own save dialog would open on top of it.
|
|
if (!localStorage.getItem(HINT_KEY)) {
|
|
localStorage.setItem(HINT_KEY, '1');
|
|
alert(t().ribbon.saveAsHint);
|
|
}
|
|
const blob = new Blob([bytes as Uint8Array<ArrayBuffer>], { type: mime });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = name;
|
|
document.body.append(a);
|
|
a.click();
|
|
a.remove();
|
|
setTimeout(() => URL.revokeObjectURL(url), 30_000);
|
|
}
|
|
|
|
async function writeHandle(handle: FileSystemFileHandle, bytes: Uint8Array): Promise<void> {
|
|
const writable = await handle.createWritable();
|
|
await writable.write(bytes as Uint8Array<ArrayBuffer>);
|
|
await writable.close();
|
|
}
|
|
|
|
// Save to the given handle if we have one; otherwise prompt for a location (i.e.
|
|
// the first save acts as Save As). Returns the handle written to, or null when
|
|
// falling back to a plain download. Throws AbortError if the user cancels.
|
|
export async function saveOdt(
|
|
bytes: Uint8Array,
|
|
suggestedName: string,
|
|
handle: FileSystemFileHandle | null,
|
|
): Promise<FileSystemFileHandle | null> {
|
|
if (!supportsFsAccess()) {
|
|
download(bytes, suggestedName);
|
|
return null;
|
|
}
|
|
const target = handle ?? (await (window as WinFs).showSaveFilePicker!({ suggestedName, types: PICKER_TYPES }));
|
|
await writeHandle(target, bytes);
|
|
return target;
|
|
}
|
|
|
|
// Always prompt for a location. Returns the new handle, or null on fallback.
|
|
export async function saveAsOdt(
|
|
bytes: Uint8Array,
|
|
suggestedName: string,
|
|
): Promise<FileSystemFileHandle | null> {
|
|
if (!supportsFsAccess()) {
|
|
download(bytes, suggestedName);
|
|
return null;
|
|
}
|
|
const handle = await (window as WinFs).showSaveFilePicker!({ suggestedName, types: PICKER_TYPES });
|
|
await writeHandle(handle, bytes);
|
|
return handle;
|
|
}
|
|
|
|
// Save to the given handle if we have one; otherwise prompt for a location. Mirrors
|
|
// saveOdt, for a document that was opened as .docx and must round-trip as .docx.
|
|
export async function saveDocx(
|
|
bytes: Uint8Array,
|
|
suggestedName: string,
|
|
handle: FileSystemFileHandle | null,
|
|
): Promise<FileSystemFileHandle | null> {
|
|
if (!supportsFsAccess()) {
|
|
download(bytes, suggestedName, DOCX_MIME);
|
|
return null;
|
|
}
|
|
const target = handle ?? (await (window as WinFs).showSaveFilePicker!({ suggestedName, types: DOCX_PICKER_TYPES }));
|
|
await writeHandle(target, bytes);
|
|
return target;
|
|
}
|
|
|
|
// Export a .docx: always prompt for a location (no handle is tracked — this is the
|
|
// explicit "Export" action, like PDF). Returns null. Throws AbortError if cancelled.
|
|
export async function saveAsDocx(bytes: Uint8Array, suggestedName: string): Promise<void> {
|
|
await saveDocx(bytes, suggestedName, null);
|
|
}
|
|
|
|
// Save a template. The picker offers both formats, so the bytes can only be built
|
|
// once the user has picked one: `build` is called with the chosen extension. Falls
|
|
// back to a plain .ott download where there is no picker.
|
|
export async function saveAsTemplate(
|
|
build: (kind: 'ott' | 'dotx') => Promise<Uint8Array>,
|
|
baseName: string,
|
|
): Promise<void> {
|
|
if (!supportsFsAccess()) {
|
|
download(await build('ott'), `${baseName}.ott`, OTT_MIME);
|
|
return;
|
|
}
|
|
const handle = await (window as WinFs).showSaveFilePicker!({
|
|
suggestedName: `${baseName}.ott`, types: TEMPLATE_PICKER_TYPES,
|
|
});
|
|
await writeHandle(handle, await build(handle.name.toLowerCase().endsWith('.dotx') ? 'dotx' : 'ott'));
|
|
}
|
|
|
|
// Prompt for an .odt/.ott/.docx to open, capturing its handle so a later save can
|
|
// overwrite the same file. Returns null if cancelled. Only call when supportsFsAccess().
|
|
export async function openOdt(): Promise<{ bytes: Uint8Array; handle: FileSystemFileHandle; name: string } | null> {
|
|
const [handle] = await (window as WinFs).showOpenFilePicker!({ types: OPEN_PICKER_TYPES, multiple: false });
|
|
if (!handle) return null;
|
|
const file = await handle.getFile();
|
|
return { bytes: new Uint8Array(await file.arrayBuffer()), handle, name: file.name };
|
|
}
|