Chromium's File System Access API (introduced in 2019)
There's a relatively new, non-standard File System Access API (not to be confused with the earlier File and Directory Entries API or the File System API). It looks like it was introduced in 2019/2020 in Chromium/Chrome, and doesn't have support in Firefox or Safari.
When using this API, a locally opened page can open/save other local files and use the files' data in the page. It does require initial permission to save, but while the user is on the page, subsequent saves of specific files do so 'silently'. A user can also grant permission to a specific directory, in which subsequent reads and writes to that directory don't require approval. Approval is needed again after the user closes all the tabs to the web page and reopens the page.
You can read more about this newish API at https://web.dev/file-system-access/. It's meant to be used to make more powerful web applications.
A few things to note about it:
By default, it requires a secure context to run. Running it on https, localhost, or through file:// should work.
You can get a file handle from dragging and dropping a file by using DataTransferItem.getAsFileSystemHandle
Initially reading or saving a file requires user approval and can only be initiated via a user interaction. After that, subsequent reads and saves don't need approval, until the site is opened again.

Handles to files can be saved in the page (so if you were editing local file '/path/to/file.txt'
, and reload the page, it would be able to have a reference to the file). They can't seemingly be stringified, so are stored through something like IndexedDB (see this answer for more info). Using stored handles to read/write requires user interaction and user approval.
Here are some simple examples. They don't seem to run in a cross-domain iframe, so you probably need to save them as an html file and open them up in Chrome/Chromium.
Opening and Saving, with Drag and Drop (no external libraries):
<body>
<div><button id="open">Open</button><button id="save">Save</button></div>
<textarea id="editor" rows=10 cols=40></textarea>
<script>
let openButton = document.getElementById('open');
let saveButton = document.getElementById('save');
let editor = document.getElementById('editor');
let fileHandle;
async function openFile() {
try {
[fileHandle] = await window.showOpenFilePicker();
await restoreFromFile(fileHandle);
} catch (e) {
// might be user canceled
}
}
async function restoreFromFile() {
let file = await fileHandle.getFile();
let text = await file.text();
editor.value = text;
}
async function saveFile() {
var saveValue = editor.value;
if (!fileHandle) {
try {
fileHandle = await window.showSaveFilePicker();
} catch (e) {
// might be user canceled
}
}
if (!fileHandle || !await verifyPermissions(fileHandle)) {
return;
}
let writableStream = await fileHandle.createWritable();
await writableStream.write(saveValue);
await writableStream.close();
}
async function verifyPermissions(handle) {
if (await handle.queryPermission({ mode: 'readwrite' }) === 'granted') {
return true;
}
if (await handle.requestPermission({ mode: 'readwrite' }) === 'granted') {
return true;
}
return false;
}
document.body.addEventListener('dragover', function (e) {
e.preventDefault();
});
document.body.addEventListener('drop', async function (e) {
e.preventDefault();
for (const item of e.dataTransfer.items) {
if (item.kind === 'file') {
let entry = await item.getAsFileSystemHandle();
if (entry.kind === 'file') {
fileHandle = entry;
restoreFromFile();
} else if (entry.kind === 'directory') {
// handle directory
}
}
}
});
openButton.addEventListener('click', openFile);
saveButton.addEventListener('click', saveFile);
</script>
</body>
Storing and Retrieving a File Handle using idb-keyval:
Storing file handles can be tricky, since they can't be unstringified, though apparently they can be used with IndexedDB and mostly with history.state
. For this example we'll use idb-keyval to access IndexedDB to store a file handle. To see it work, open or save a file, and then reload the page and press the 'Restore' button. This example uses some code from https://stackoverflow.com/a/65938910/.
<body>
<script src="https://unpkg.com/idb-keyval@6.1.0/dist/umd.js"></script>
<div><button id="restore" style="display:none">Restore</button><button id="open">Open</button><button id="save">Save</button></div>
<textarea id="editor" rows=10 cols=40></textarea>
<script>
let restoreButton = document.getElementById('restore');
let openButton = document.getElementById('open');
let saveButton = document.getElementById('save');
let editor = document.getElementById('editor');
let fileHandle;
async function openFile() {
try {
[fileHandle] = await window.showOpenFilePicker();
await restoreFromFile(fileHandle);
} catch (e) {
// might be user canceled
}
}
async function restoreFromFile() {
let file = await fileHandle.getFile();
let text = await file.text();
await idbKeyval.set('file', fileHandle);
editor.value = text;
restoreButton.style.display = 'none';
}
async function saveFile() {
var saveValue = editor.value;
if (!fileHandle) {
try {
fileHandle = await window.showSaveFilePicker();
await idbKeyval.set('file', fileHandle);
} catch (e) {
// might be user canceled
}
}
if (!fileHandle || !await verifyPermissions(fileHandle)) {
return;
}
let writableStream = await fileHandle.createWritable();
await writableStream.write(saveValue);
await writableStream.close();
restoreButton.style.display = 'none';
}
async function verifyPermissions(handle) {
if (await handle.queryPermission({ mode: 'readwrite' }) === 'granted') {
return true;
}
if (await handle.requestPermission({ mode: 'readwrite' }) === 'granted') {
return true;
}
return false;
}
async function init() {
var previousFileHandle = await idbKeyval.get('file');
if (previousFileHandle) {
restoreButton.style.display = 'inline-block';
restoreButton.addEventListener('click', async function (e) {
if (await verifyPermissions(previousFileHandle)) {
fileHandle = previousFileHandle;
await restoreFromFile();
}
});
}
document.body.addEventListener('dragover', function (e) {
e.preventDefault();
});
document.body.addEventListener('drop', async function (e) {
e.preventDefault();
for (const item of e.dataTransfer.items) {
console.log(item);
if (item.kind === 'file') {
let entry = await item.getAsFileSystemHandle();
if (entry.kind === 'file') {
fileHandle = entry;
restoreFromFile();
} else if (entry.kind === 'directory') {
// handle directory
}
}
}
});
openButton.addEventListener('click', openFile);
saveButton.addEventListener('click', saveFile);
}
init();
</script>
</body>
Additional Notes
Firefox and Safari support seems to be unlikely, at least in the near term. See https://github.com/mozilla/standards-positions/issues/154 and https://lists.webkit.org/pipermail/webkit-dev/2020-August/031362.html