diff --git a/index.html b/index.html index 132fa52..bf8e118 100644 --- a/index.html +++ b/index.html @@ -71,6 +71,8 @@ 📄 Load + + diff --git a/src/project_save_handler.js b/src/project_save_handler.js index 18b95ce..69bd51f 100644 --- a/src/project_save_handler.js +++ b/src/project_save_handler.js @@ -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; + } } diff --git a/src/utils.js b/src/utils.js index 85ed80f..5f5dd43 100644 --- a/src/utils.js +++ b/src/utils.js @@ -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+/) diff --git a/style.css b/style.css index 7fb4849..a6bdfd6 100644 --- a/style.css +++ b/style.css @@ -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; }