use chromium save file picker when available

This commit is contained in:
Reimar 2025-10-15 20:05:07 +02:00
parent f2b5a8d257
commit d4dcd9bc28
4 changed files with 106 additions and 62 deletions

View File

@ -71,6 +71,8 @@
<button id="load-button" class="secondary">
📄 Load
</button>
<span id="save-status"></span>
</div>
<button id="export-button">

View File

@ -1,5 +1,5 @@
import { promptUpload } from "./prompt_upload.js";
import { downloadFile, slugify } from "./utils.js";
import { slugify } from "./utils.js";
import { HtmlExporter } from "./html_exporter.js";
import { KarlkoderCodec } from "./karlkoder_codec.js";
@ -11,6 +11,7 @@ export class ProjectSaveHandler {
this.htmlExporter = new HtmlExporter(assetProvider);
this.fileHandles = {}; // Used for Chromium file picker
this.saveName = null;
this.isSaved = true;
@ -78,36 +79,39 @@ export class ProjectSaveHandler {
}
async saveFile() {
const filename = prompt("Filename?", this.getSaveFileName());
if (!filename) {
return;
}
this.saveName = filename;
this.isSaved = true;
downloadFile(
filename,
const saved = await this.downloadFile(
"project",
this.getSaveFileName(),
await KarlkoderCodec.en(
this.projectName.value,
this.editor.getValue(),
await this.assetEditor.getAssets(),
),
".karlkode",
"application/x-karlkode",
);
this.isSaved = saved;
if (globalThis.chrome) {
this.showSavedStatus(saved, saved ? "Saved" : "Not saved");
}
}
async exportProject() {
const filename = prompt("Filename?", this.getSaveFileName());
if (!filename) {
return;
}
this.saveName = filename;
const html = await this.htmlExporter.export(this.projectName.value, this.editor.getValue());
downloadFile(filename, html, ".html", "text/html");
const exported = await this.downloadFile(
"export",
this.getSaveFileName(),
html,
".html",
"text/html",
);
if (globalThis.chrome) {
this.showSavedStatus(exported, exported ? "Exported" : "Not exported");
}
}
getSaveFileName() {
@ -117,4 +121,72 @@ export class ProjectSaveHandler {
return slugify(this.projectName.value) || "project";
}
showSavedStatus(success, status) {
const saveStatus = document.querySelector("#save-status");
saveStatus.textContent = (success ? "✓ " : "× ") + status;
saveStatus.style.backgroundColor = success ? "#43A047" : "#E53935";
saveStatus.style.transition = "";
saveStatus.style.opacity = 1;
setTimeout(() => {
saveStatus.style.transition = "opacity 1s ease-out";
saveStatus.style.opacity = 0;
}, 1500);
}
async downloadFile(id, name, content, extension, mime) {
if ("showSaveFilePicker" in globalThis) {
try {
this.fileHandles[id] ??= await globalThis.showSaveFilePicker({
id: "karlkoder",
startIn: "documents",
suggestedName: name + extension,
types: [{
accept: {
[mime]: [extension],
},
}],
});
const stream = await this.fileHandles[id].createWritable();
await stream.write(content);
await stream.close();
return true;
} catch (e) {
// Throws AbortError or NotAllowedError on cancel
console.log("file picker error", e);
this.fileHandles[id] = null; // Ask for new file handle next time
return false;
}
}
const filename = prompt("Filename?", name);
if (!filename) {
return false;
}
this.saveName = filename;
const blob = new Blob([content], { type: mime });
const url = URL.createObjectURL(blob);
const element = document.createElement("a");
element.href = url;
element.download = filename.endsWith(extension) ? filename : filename + extension;
element.style.display = "none";
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
return true;
}
}

View File

@ -1,20 +1,3 @@
export function downloadFile(filename, content, extension, mime) {
const blob = new Blob([content], { type: mime });
const url = URL.createObjectURL(blob);
const element = document.createElement("a");
element.href = url;
element.download = filename.endsWith(extension) ? filename : filename + extension;
element.style.display = "none";
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
}
export function slugify(text) {
return text
.split(/\W+/)

View File

@ -221,32 +221,6 @@ div#buttons button {
box-shadow: none;
}
.dropdown-wrapper {
position: relative;
}
.dropdown-content {
display: none;
background-color: white;
position: absolute;
top: 100%;
left: 0;
right: 0;
border-radius: 0.25rem;
box-shadow: 2px 2px 2px rgba(0, 0, 0, 0.1);
}
.dropdown-option {
padding: 0.3rem 0.6rem;
color: #424242;
font-size: 0.8rem;
cursor: pointer;
}
.dropdown-option:hover {
background-color: #eee;
}
button {
background-color: #087aaf;
border: none;
@ -262,6 +236,10 @@ button:hover {
box-shadow: 2px 2px 2px rgba(0, 0, 0, 0.2);
}
button:active {
background-color: #06577e;
}
button.secondary {
background-color: #616161;
}
@ -415,6 +393,7 @@ footer {
#project-header-left {
display: flex;
align-items: center;
gap: 0.5rem;
}
@ -435,6 +414,14 @@ footer {
outline: none;
}
#save-status {
white-space: nowrap;
font-size: 0.8rem;
margin-left: 0.5rem;
padding: 0.125rem 0.75rem;
border-radius: 0.5rem;
}
#export-button {
justify-self: flex-end;
}