From 64948c4526dd5a8bce1e9e57c00c435ca3c66467 Mon Sep 17 00:00:00 2001 From: verylowfreq <60875431+verylowfreq@users.noreply.github.com> Date: Sun, 7 Aug 2022 18:32:49 +0900 Subject: [PATCH] Web: Add opening/saving file support. - Opening file is implemented as uploading. - Saving file is implemented as downloading. - The filename is suffixed with current date and time. --- src/platform/guihtml.cpp | 223 +++++++++++++++++++++- src/platform/html/solvespaceui.js | 297 ++++++++++++++++++++++++++++++ src/solvespace.cpp | 15 +- src/solvespace.h | 1 + 4 files changed, 528 insertions(+), 8 deletions(-) diff --git a/src/platform/guihtml.cpp b/src/platform/guihtml.cpp index 4ed77de..56950a4 100644 --- a/src/platform/guihtml.cpp +++ b/src/platform/guihtml.cpp @@ -78,7 +78,7 @@ static val Wrap(std::function *func) { //----------------------------------------------------------------------------- void FatalError(const std::string &message) { - fprintf(stderr, "%s", message.c_str()); + dbp("%s", message.c_str()); #ifndef NDEBUG emscripten_debugger(); #endif @@ -808,6 +808,10 @@ public: std::vector> responseFuncs; + bool is_shown = false; + + Response latestResponse = Response::NONE; + MessageDialogImplHtml() : htmlModal(val::global("document").call("createElement", val("div"))), htmlDialog(val::global("document").call("createElement", val("div"))), @@ -850,6 +854,7 @@ public: std::function responseFunc = [this, response] { htmlModal.call("remove"); + this->latestResponse = response; if(onResponse) { onResponse(response); } @@ -860,16 +865,43 @@ public: responseFuncs.push_back(responseFunc); htmlButton.call("addEventListener", val("trigger"), Wrap(&responseFuncs.back())); + static std::function updateShowFlagFunc = [this] { + this->is_shown = false; + }; + htmlButton.call("addEventListener", val("trigger"), Wrap(&updateShowFlagFunc)); + htmlButtons.call("appendChild", htmlButton); } Response RunModal() { - ssassert(false, "RunModal not supported on Emscripten"); + // ssassert(false, "RunModal not supported on Emscripten"); + dbp("MessageDialog::RunModal() called."); + this->ShowModal(); + //FIXME(emscripten): use val::await() with JavaScript's Promise + while (true) { + if (this->is_shown) { + dbp("MessageDialog::RunModal(): is_shown == true"); + emscripten_sleep(2000); + } else { + dbp("MessageDialog::RunModal(): break due to is_shown == false"); + break; + } + } + + if (this->latestResponse != Response::NONE) { + return this->latestResponse; + } else { + // FIXME(emscripten): + dbp("MessageDialog::RunModal(): Cannot get Response."); + return this->latestResponse; + } } void ShowModal() { dialogsOnScreen.push_back(shared_from_this()); val::global("document")["body"].call("appendChild", htmlModal); + + this->is_shown = true; } }; @@ -881,20 +913,187 @@ MessageDialogRef CreateMessageDialog(WindowRef parentWindow) { // File dialogs //----------------------------------------------------------------------------- -class FileDialogImplHtml : public FileDialog { +class FileOpenDialogImplHtml : public FileDialog { public: - // FIXME(emscripten): implement + std::string title; + std::string filename; + std::string filters; + + emscripten::val fileUploadHelper; + + FileOpenDialogImplHtml() { + //FIXME(emscripten): + dbp("FileOpenDialogImplHtml::FileOpenDialogImplHtml()"); + // FIXME(emscripten): workaround. following code raises "constructor is not a constructor" exception. + // val fuh = val::global("FileUploadHelper"); + // this->fileUploadHelper = fuh.new_(); + this->fileUploadHelper = val::global().call("createFileUploadHelperInstance"); + dbp("FileOpenDialogImplHtml::FileOpenDialogImplHtml() OK."); + } + + ~FileOpenDialogImplHtml() override { + dbp("FileOpenDialogImplHtml::~FileOpenDialogImplHtml()"); + this->fileUploadHelper.call("dispose"); + } + + void SetTitle(std::string title) override { + //FIXME(emscripten): + dbp("FileOpenDialogImplHtml::SetTitle(): title=%s", title.c_str()); + this->title = title; + //FIXME(emscripten): + this->fileUploadHelper.set("title", val(this->title)); + } + + void SetCurrentName(std::string name) override { + //FIXME(emscripten): + dbp("FileOpenDialogImplHtml::SetCurrentName(): name=%s", name.c_str()); + SetFilename(GetFilename().Parent().Join(name)); + } + + Platform::Path GetFilename() override { + //FIXME(emscripten): + dbp("FileOpenDialogImplHtml::GetFilename()"); + return Platform::Path::From(this->filename.c_str()); + } + + void SetFilename(Platform::Path path) override { + //FIXME(emscripten): + dbp("FileOpenDialogImplHtml::SetFilename(): path=%s", path.raw.c_str()); + this->filename = path.raw; + //FIXME(emscripten): + this->fileUploadHelper.set("filename", val(this->filename)); + } + + void SuggestFilename(Platform::Path path) override { + //FIXME(emscripten): + dbp("FileOpenDialogImplHtml::SuggestFilename(): path=%s", path.raw.c_str()); + SetFilename(Platform::Path::From(path.FileStem())); + } + + void AddFilter(std::string name, std::vector extensions) override { + //FIXME(emscripten): + dbp("FileOpenDialogImplHtml::AddFilter()"); + this->filters = ""; + for (auto extension : extensions) { + this->filters = "." + extension; + this->filters += ","; + } + dbp("filter=%s", this->filters.c_str()); + } + + void FreezeChoices(SettingsRef settings, const std::string &key) override { + //FIXME(emscripten): + dbp("FileOpenDialogImplHtml::FreezeChoise()"); + } + + void ThawChoices(SettingsRef settings, const std::string &key) override { + //FIXME(emscripten): + dbp("FileOpenDialogImplHtml::ThawChoices()"); + } + + bool RunModal() override { + //FIXME(emscripten): + dbp("FileOpenDialogImplHtml::RunModal()"); + this->filename = "untitled.slvs"; + this->fileUploadHelper.call("showDialog"); + + //FIXME(emscripten): use val::await() with JavaScript's Promise + dbp("FileOpenDialogImplHtml: start loop"); + // Wait until fileUploadHelper.is_shown == false + while (true) { + bool is_shown = this->fileUploadHelper["is_shown"].as(); + if (!is_shown) { + dbp("FileOpenDialogImplHtml: break due to is_shown == false"); + break; + } else { + // dbp("FileOpenDialogImplHtml: sleep 100msec... (%d)", is_shown); + emscripten_sleep(100); + } + } + + val selectedFilenameVal = this->fileUploadHelper["currentFilename"]; + + if (selectedFilenameVal == val::null()) { + dbp("selectedFilenameVal is null"); + return false; + } else { + std::string selectedFilename = selectedFilenameVal.as(); + this->filename = selectedFilename; + return true; + } + } +}; + +class FileSaveDummyDialogImplHtml : public FileDialog { +public: + std::string title; + std::string filename; + std::string filters; + + FileSaveDummyDialogImplHtml() { + + + } + + ~FileSaveDummyDialogImplHtml() override { + + } + + void SetTitle(std::string title) override { + this->title = title; + } + + void SetCurrentName(std::string name) override { + SetFilename(GetFilename().Parent().Join(name)); + } + + Platform::Path GetFilename() override { + return Platform::Path::From(this->filename.c_str()); + } + + void SetFilename(Platform::Path path) override { + this->filename = path.raw; + } + + void SuggestFilename(Platform::Path path) override { + SetFilename(Platform::Path::From(path.FileStem())); + } + + void AddFilter(std::string name, std::vector extensions) override { + this->filters = ""; + for (auto extension : extensions) { + this->filters = "." + extension; + this->filters += ","; + } + dbp("filter=%s", this->filters.c_str()); + } + + void FreezeChoices(SettingsRef settings, const std::string &key) override { + + } + + void ThawChoices(SettingsRef settings, const std::string &key) override { + + } + + bool RunModal() override { + if (this->filename.length() < 1) { + this->filename = "untitled.slvs"; + } + return true; + } }; FileDialogRef CreateOpenFileDialog(WindowRef parentWindow) { // FIXME(emscripten): implement - return std::shared_ptr(); - + dbp("CreateOpenFileDialog()"); + return std::shared_ptr(new FileOpenDialogImplHtml()); } FileDialogRef CreateSaveFileDialog(WindowRef parentWindow) { // FIXME(emscripten): implement - return std::shared_ptr(); + dbp("CreateSaveFileDialog()"); + return std::shared_ptr(new FileSaveDummyDialogImplHtml()); } //----------------------------------------------------------------------------- @@ -909,11 +1108,21 @@ void OpenInBrowser(const std::string &url) { val::global("window").call("open", Wrap(url)); } + +void OnSaveFinishedCallback(const Platform::Path& filename, bool is_saveAs, bool is_autosave) { + dbp("OnSaveFinished(): %s, is_saveAs=%d, is_autosave=%d\n", filename.FileName().c_str(), is_saveAs, is_autosave); + std::string filename_str = filename.FileName(); + EM_ASM(saveFileDone(UTF8ToString($0), $1, $2), filename_str.c_str(), is_saveAs, is_autosave); +} + std::vector InitGui(int argc, char **argv) { static std::function onBeforeUnload = std::bind(&SolveSpaceUI::Exit, &SS); val::global("window").call("addEventListener", val("beforeunload"), Wrap(&onBeforeUnload)); + dbp("Set onSaveFinished"); + SS.OnSaveFinished = OnSaveFinishedCallback; + // FIXME(emscripten): get locale from user preferences SetLocale("en_US"); diff --git a/src/platform/html/solvespaceui.js b/src/platform/html/solvespaceui.js index 7978659..4a38759 100644 --- a/src/platform/html/solvespaceui.js +++ b/src/platform/html/solvespaceui.js @@ -11,6 +11,34 @@ function isModal() { return hasModal || hasMenuBar || hasPopupMenu || hasEditor; } +/* String helpers */ + +/** + * @param {string} s - original string + * @param {number} digits - char length of generating string + * @param {string} ch - string to be used for padding + * @return {string} generated string ($digits chars length) or $s + */ +function stringPadLeft(s, digits, ch) { + if (s.length > digits) { + return s; + } + for (let i = s.length; i < digits; i++) { + s = ch + s; + } + return s; +} + +/** Generate a string expression of now + * @return {string} like a "2022_08_31_2245" string (for 2022-08-31 22:45; local time) + */ +function GetCurrentDateTimeString() { + const now = new Date(); + const padLeft2 = (num) => { return stringPadLeft(num.toString(), 2, '0') }; + return (`${now.getFullYear()}_${padLeft2(now.getMonth()+1)}_${padLeft2(now.getDate())}` + + `_` + `${padLeft2(now.getHours())}${padLeft2(now.getMinutes())}`); +} + /* CSS helpers */ function hasClass(element, className) { return element.classList.contains(className); @@ -346,3 +374,272 @@ window.addEventListener('keyup', function(event) { removeClass(document.body, 'mnemonic'); } }); + + +// FIXME(emscripten): Should be implemnted in guihtmlcpp ? +class FileUploadHelper { + constructor() { + this.modalRoot = document.createElement("div"); + addClass(this.modalRoot, "modal"); + this.modalRoot.style.display = "none"; + this.modalRoot.style.zIndex = 1000; + + this.dialogRoot = document.createElement("div"); + addClass(this.dialogRoot, "dialog"); + this.modalRoot.appendChild(this.dialogRoot); + + this.messageHeader = document.createElement("strong"); + this.dialogRoot.appendChild(this.messageHeader); + + this.descriptionParagraph = document.createElement("p"); + this.dialogRoot.appendChild(this.descriptionParagraph); + + this.currentFileListHeader = document.createElement("p"); + this.currentFileListHeader.textContent = "Current uploaded files:"; + this.dialogRoot.appendChild(this.currentFileListHeader); + + this.currentFileList = document.createElement("div"); + this.dialogRoot.appendChild(this.currentFileList); + + this.fileInputContainer = document.createElement("div"); + + this.fileInputElement = document.createElement("input"); + this.fileInputElement.setAttribute("type", "file"); + this.fileInputElement.addEventListener("change", (ev)=> this.onFileInputChanged(ev)); + this.fileInputContainer.appendChild(this.fileInputElement); + + this.dialogRoot.appendChild(this.fileInputContainer); + + this.buttonHolder = document.createElement("div"); + addClass(this.buttonHolder, "buttons"); + this.dialogRoot.appendChild(this.buttonHolder); + + this.AddButton("OK", 0, false); + this.AddButton("Cancel", 1, true); + + this.closeDialog(); + + document.querySelector("body").appendChild(this.modalRoot); + + this.currentFilename = null; + + // FIXME(emscripten): For debugging + this.title = ""; + this.filename = ""; + this.filters = ""; + } + + dispose() { + document.querySelector("body").removeChild(this.modalRoot); + } + + AddButton(label, response, isDefault) { + // FIXME(emscripten): implement + const buttonElem = document.createElement("div"); + addClass(buttonElem, "button"); + setLabelWithMnemonic(buttonElem, label); + if (isDefault) { + addClass(buttonElem, "default"); + addClass(buttonElem, "selected"); + } + buttonElem.addEventListener("click", () => { + this.closeDialog(); + }); + + this.buttonHolder.appendChild(buttonElem); + } + + getFileEntries() { + const basePath = '/'; + /** @type {Array { + return FS.isFile(FS.lstat(basePath + nodename).mode); + }).map((filename) => { + return basePath + filename; + }); + return files; + } + + generateFileList() { + let filepaths = this.getFileEntries(); + const listElem = document.createElement("ul"); + for (let i = 0; i < filepaths.length; i++) { + const listitemElem = document.createElement("li"); + const stat = FS.lstat(filepaths[i]); + const text = `"${filepaths[i]}" (${stat.size} bytes)`; + listitemElem.textContent = text; + listElem.appendChild(listitemElem); + } + return listElem; + } + + updateFileList() { + this.currentFileList.innerHTML = ""; + this.currentFileList.appendChild(this.generateFileList()); + } + + onFileInputChanged(ev) { + const selectedFiles = ev.target.files; + if (selectedFiles.length < 1) { + return; + } + const selectedFile = selectedFiles[0]; + const selectedFilename = selectedFile.name; + this.filename = selectedFilename; + this.currentFilename = selectedFilename; + + // Prepare FileReader + const fileReader = new FileReader(); + const fileReaderReadAsArrayBufferPromise = new Promise((resolve, reject) => { + fileReader.addEventListener("load", (ev) => { + resolve(ev.target.result); + }); + fileReader.addEventListener("abort", (err) => { + reject(err); + }); + fileReader.readAsArrayBuffer(selectedFile); + }); + + fileReaderReadAsArrayBufferPromise + .then((arrayBuffer) => { + // Write selected file to FS + console.log(`Write uploaded file blob to filesystem. "${selectedFilename}" (${arrayBuffer.byteLength} bytes)`); + const u8array = new Uint8Array(arrayBuffer); + const fs = FS.open("/" + selectedFilename, "w"); + FS.write(fs, u8array, 0, u8array.length, 0); + FS.close(fs); + + // Update file list in dialog + this.updateFileList(); + }) + .catch((err) => { + console.error("Error while fileReader.readAsArrayBuffer():", err); + }); + } + + showDialog() { + this.updateFileList(); + + this.is_shown = true; + this.modalRoot.style.display = "block"; + } + + closeDialog() { + this.is_shown = false; + this.modalRoot.style.display = "none"; + } +}; + +// FIXME(emscripten): Workaround +function createFileUploadHelperInstance() { + return new FileUploadHelper(); +} + +// FIXME(emscripten): Should be implemnted in guihtmlcpp ? +class FileDownloadHelper { + constructor() { + this.modalRoot = document.createElement("div"); + addClass(this.modalRoot, "modal"); + this.modalRoot.style.display = "none"; + this.modalRoot.style.zIndex = 1000; + + this.dialogRoot = document.createElement("div"); + addClass(this.dialogRoot, "dialog"); + this.modalRoot.appendChild(this.dialogRoot); + + this.messageHeader = document.createElement("strong"); + this.dialogRoot.appendChild(this.messageHeader); + + this.descriptionParagraph = document.createElement("p"); + this.dialogRoot.appendChild(this.descriptionParagraph); + + this.buttonHolder = document.createElement("div"); + addClass(this.buttonHolder, "buttons"); + this.dialogRoot.appendChild(this.buttonHolder); + + this.closeDialog(); + + document.querySelector("body").appendChild(this.modalRoot); + } + + dispose() { + document.querySelector("body").removeChild(this.modalRoot); + } + + AddButton(label, response, isDefault) { + // FIXME(emscripten): implement + const buttonElem = document.createElement("div"); + addClass(buttonElem, "button"); + setLabelWithMnemonic(buttonElem, label); + if (isDefault) { + addClass(buttonElem, "default"); + addClass(buttonElem, "selected"); + } + buttonElem.addEventListener("click", () => { + this.closeDialog(); + this.dispose(); + }); + + this.buttonHolder.appendChild(buttonElem); + } + + createBlobURLFromArrayBuffer(arrayBuffer) { + const u8array = new Uint8Array(arrayBuffer); + let dataUrl = "data:application/octet-stream;base64,"; + let binaryString = ""; + for (let i = 0; i < u8array.length; i++) { + binaryString += String.fromCharCode(u8array[i]); + } + dataUrl += btoa(binaryString); + + return dataUrl; + } + + prepareFile(filename) { + this.messageHeader.textContent = "Your file ready"; + + const stat = FS.lstat(filename); + const filesize = stat.size; + const fs = FS.open(filename, "r"); + const readbuffer = new Uint8Array(filesize); + FS.read(fs, readbuffer, 0, filesize, 0); + FS.close(fs); + + const blobURL = this.createBlobURLFromArrayBuffer(readbuffer.buffer); + + this.descriptionParagraph.innerHTML = ""; + const linkElem = document.createElement("a"); + let downloadfilename = "solvespace_browser-"; + downloadfilename += `${GetCurrentDateTimeString()}.slvs`; + linkElem.setAttribute("download", downloadfilename); + linkElem.setAttribute("href", blobURL); + // WORKAROUND: FIXME(emscripten) + linkElem.style.color = "lightblue"; + linkElem.textContent = downloadfilename; + this.descriptionParagraph.appendChild(linkElem); + } + + showDialog() { + this.is_shown = true; + this.modalRoot.style.display = "block"; + } + + closeDialog() { + this.is_shown = false; + this.modalRoot.style.display = "none"; + } +}; + +function saveFileDone(filename, isSaveAs, isAutosave) { + console.log(`saveFileDone(${filename}, ${isSaveAs}, ${isAutosave})`); + if (isAutosave) { + return; + } + const fileDownloadHelper = new FileDownloadHelper(); + fileDownloadHelper.AddButton("OK", 0, true); + fileDownloadHelper.prepareFile(filename); + console.log(`Calling shoDialog()...`); + fileDownloadHelper.showDialog(); + console.log(`shoDialog() finished.`); +} diff --git a/src/solvespace.cpp b/src/solvespace.cpp index ee30dbd..fd0cb43 100644 --- a/src/solvespace.cpp +++ b/src/solvespace.cpp @@ -560,12 +560,18 @@ bool SolveSpaceUI::GetFilenameAndSave(bool saveAs) { if(saveAs || saveFile.IsEmpty()) { Platform::FileDialogRef dialog = Platform::CreateSaveFileDialog(GW.window); + // FIXME(emscripten): + dbp("Calling AddFilter()..."); dialog->AddFilter(C_("file-type", "SolveSpace models"), { SKETCH_EXT }); + dbp("Calling ThawChoices()..."); dialog->ThawChoices(settings, "Sketch"); if(!newSaveFile.IsEmpty()) { + dbp("Calling SetFilename()..."); dialog->SetFilename(newSaveFile); } + dbp("Calling RunModal()..."); if(dialog->RunModal()) { + dbp("Calling FreezeChoices()..."); dialog->FreezeChoices(settings, "Sketch"); newSaveFile = dialog->GetFilename(); } else { @@ -578,6 +584,9 @@ bool SolveSpaceUI::GetFilenameAndSave(bool saveAs) { RemoveAutosave(); saveFile = newSaveFile; unsaved = false; + if (this->OnSaveFinished) { + this->OnSaveFinished(newSaveFile, saveAs, false); + } return true; } else { return false; @@ -589,7 +598,11 @@ void SolveSpaceUI::Autosave() ScheduleAutosave(); if(!saveFile.IsEmpty() && unsaved) { - SaveToFile(saveFile.WithExtension(BACKUP_EXT)); + Platform::Path saveFileName = saveFile.WithExtension(BACKUP_EXT); + SaveToFile(saveFileName); + if (this->OnSaveFinished) { + this->OnSaveFinished(saveFileName, false, true); + } } } diff --git a/src/solvespace.h b/src/solvespace.h index fbae19e..2568a70 100644 --- a/src/solvespace.h +++ b/src/solvespace.h @@ -683,6 +683,7 @@ public: void NewFile(); bool SaveToFile(const Platform::Path &filename); bool LoadAutosaveFor(const Platform::Path &filename); + std::function OnSaveFinished; bool LoadFromFile(const Platform::Path &filename, bool canCancel = false); void UpgradeLegacyData(); bool LoadEntitiesFromFile(const Platform::Path &filename, EntityList *le,