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.
This commit is contained in:
verylowfreq 2022-08-07 18:32:49 +09:00 committed by ruevs
parent 56b9d36030
commit 64948c4526
4 changed files with 528 additions and 8 deletions

View File

@ -78,7 +78,7 @@ static val Wrap(std::function<void()> *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<std::function<void()>> responseFuncs;
bool is_shown = false;
Response latestResponse = Response::NONE;
MessageDialogImplHtml() :
htmlModal(val::global("document").call<val>("createElement", val("div"))),
htmlDialog(val::global("document").call<val>("createElement", val("div"))),
@ -850,6 +854,7 @@ public:
std::function<void()> responseFunc = [this, response] {
htmlModal.call<void>("remove");
this->latestResponse = response;
if(onResponse) {
onResponse(response);
}
@ -860,16 +865,43 @@ public:
responseFuncs.push_back(responseFunc);
htmlButton.call<void>("addEventListener", val("trigger"), Wrap(&responseFuncs.back()));
static std::function<void()> updateShowFlagFunc = [this] {
this->is_shown = false;
};
htmlButton.call<void>("addEventListener", val("trigger"), Wrap(&updateShowFlagFunc));
htmlButtons.call<void>("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<void>("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<val>("createFileUploadHelperInstance");
dbp("FileOpenDialogImplHtml::FileOpenDialogImplHtml() OK.");
}
~FileOpenDialogImplHtml() override {
dbp("FileOpenDialogImplHtml::~FileOpenDialogImplHtml()");
this->fileUploadHelper.call<void>("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<std::string> 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<void>("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<bool>();
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<std::string>();
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<std::string> 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<FileDialogImplHtml>();
dbp("CreateOpenFileDialog()");
return std::shared_ptr<FileOpenDialogImplHtml>(new FileOpenDialogImplHtml());
}
FileDialogRef CreateSaveFileDialog(WindowRef parentWindow) {
// FIXME(emscripten): implement
return std::shared_ptr<FileDialogImplHtml>();
dbp("CreateSaveFileDialog()");
return std::shared_ptr<FileSaveDummyDialogImplHtml>(new FileSaveDummyDialogImplHtml());
}
//-----------------------------------------------------------------------------
@ -909,11 +1108,21 @@ void OpenInBrowser(const std::string &url) {
val::global("window").call<void>("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<std::string> InitGui(int argc, char **argv) {
static std::function<void()> onBeforeUnload = std::bind(&SolveSpaceUI::Exit, &SS);
val::global("window").call<void>("addEventListener", val("beforeunload"),
Wrap(&onBeforeUnload));
dbp("Set onSaveFinished");
SS.OnSaveFinished = OnSaveFinishedCallback;
// FIXME(emscripten): get locale from user preferences
SetLocale("en_US");

View File

@ -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<object} */
const nodes = FS.readdir(basePath);
const files = nodes.filter((nodename) => {
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.`);
}

View File

@ -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);
}
}
}

View File

@ -683,6 +683,7 @@ public:
void NewFile();
bool SaveToFile(const Platform::Path &filename);
bool LoadAutosaveFor(const Platform::Path &filename);
std::function<void(const Platform::Path &filename, bool is_saveAs, bool is_autosave)> OnSaveFinished;
bool LoadFromFile(const Platform::Path &filename, bool canCancel = false);
void UpgradeLegacyData();
bool LoadEntitiesFromFile(const Platform::Path &filename, EntityList *le,