Skip to content

Commit

Permalink
feature-save-file (#92)
Browse files Browse the repository at this point in the history
demo: https://3d.hrg.hr/jscad/save/

like mentioned in
#88 (comment)

feature  to allow `ctrl+s` to save file from editors

basic flow is initally implemented for solo script in the editor, 
- [x] first save opens dialog
- [x] subsequent save saves into the same file

more to do
- [x] change internal file name to the file name that dropped (single
file)
- [x] dropped (single file) without save dialog
- [x] provide file name proposal
- [x] reset reference to file name after new script is loaded examples
or other source
- [x] reset file entry list when example loaded after folder loaded
- [x] use file handle from drag and drop to save the same file
- [x] change internal file name to the file name that was selected

not caused here
- [x] refresh editor when file is changed on disk (change is seen in
render result, but not in editor source)



https://github.com/hrgdavor/jscadui/assets/2480762/31bc8ea9-180d-488f-8c57-694bc8588156
  • Loading branch information
hrgdavor authored Jan 29, 2024
1 parent fc619ac commit 02f8612
Show file tree
Hide file tree
Showing 6 changed files with 185 additions and 58 deletions.
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -107,4 +107,6 @@ dist
.turbo
build/**
dist/**
**/.history
**/.history
**/.idea

151 changes: 111 additions & 40 deletions apps/jscad-web/main.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
import { addToCache, extractEntries, fileDropped, getFile, registerServiceWorker } from '@jscadui/fs-provider'
import {
addToCache,
clearFs,
extractEntries,
fileDropped,
getFile,
getFileContent,
registerServiceWorker,
} from '@jscadui/fs-provider'
import { Gizmo } from '@jscadui/html-gizmo'
import { OrbitControl } from '@jscadui/orbit'
import { genParams } from '@jscadui/params'
Expand Down Expand Up @@ -47,20 +55,34 @@ ctrl.oninput = state => updateFromCtrl(state)
gizmo.oncam = ({ cam }) => ctrl.animateToCommonCamera(cam)

let sw
async function resetFileRefs(){
editor.setFiles([])
saveMap = {}
if(sw){
delete sw.fileToRun
await clearFs(sw)
}
}

async function initFs() {
const getFileWrapper = (path, sw) => {
const file = getFile(path, sw)
const file = getFileContent(path, sw)
// notify editor of active files
file.then(() => editor.setFiles(sw.filesToCheck))
return file
}
let scope = document.location.pathname
sw = await registerServiceWorker(`bundle.fs-serviceworker.js?prefix=${scope}swfs/`, getFileWrapper, {scope, prefix:scope+'swfs/'})
sw = await registerServiceWorker(`bundle.fs-serviceworker.js?prefix=${scope}swfs/`, getFileWrapper, {
scope,
prefix: scope + 'swfs/',
})
sw.defProjectName = 'jscad'
sw.onfileschange = files => {
sendNotify('clearFileCache', { files })
editor.filesChanged(files)
if (sw.fileToRun) runScript({ url: sw.fileToRun, base: sw.base })
}
sw.getFile = path => getFile(path, sw)
}
const dropModal = byId('dropModal')
const showDrop = show => {
Expand All @@ -70,19 +92,21 @@ const showDrop = show => {
document.body.ondrop = async ev => {
try {
ev.preventDefault()
let files = extractEntries(ev.dataTransfer)
let files = await extractEntries(ev.dataTransfer)
if (!files.length) return {}

await resetFileRefs()
if (!sw) await initFs()
showDrop(false)
sendCmd('clearTempCache', {})
saveMap = {}
const { alias, script } = await fileDropped(sw, files)
projectName = sw.projectName
if (alias.length) {
sendNotify('init', { alias })
}
runScript({ url: sw.fileToRun, base: sw.base })
editor.setSource(script, sw.fileToRun)
let url = sw.fileToRun
runScript({ url, base: sw.base })
editor.setSource(script, url)
editor.setFiles(sw.filesToCheck)
} catch (error) {
setError(error)
Expand Down Expand Up @@ -135,7 +159,7 @@ const exportModel = async (format, extension) => {

const worker = new Worker('./build/bundle.worker.js')
const handlers = {
entities: ({ entities, mainTime, convertTime}) => {
entities: ({ entities, mainTime, convertTime }) => {
if (!(entities instanceof Array)) entities = [entities]
viewState.setModel((model = entities))
console.log('Main execution:', mainTime?.toFixed(2), ', jscad mesh -> gl:', convertTime?.toFixed(2))
Expand All @@ -149,11 +173,11 @@ let jobs = 0
let firstJobTimer
async function sendCmdAndSpin(method, params) {
jobs++
if(jobs === 1){
if (jobs === 1) {
// do not show spinner for fast renders
firstJobTimer = setTimeout(()=>{
firstJobTimer = setTimeout(() => {
spinner.style.display = 'block'
},300)
}, 300)
}
try {
return await sendCmd(method, params)
Expand All @@ -169,7 +193,8 @@ async function sendCmdAndSpin(method, params) {
}

sendCmdAndSpin('init', {
bundles: {// local bundled alias for common libs.
bundles: {
// local bundled alias for common libs.
'@jscad/modeling': toUrl('./build/bundle.jscad_modeling.js'),
'@jscad/io': toUrl('./build/bundle.jscad_io.js'),
},
Expand All @@ -182,9 +207,9 @@ sendCmdAndSpin('init', {
let working
let lastParams
const paramChangeCallback = async params => {
if(!working){
if (!working) {
lastParams = null
}else{
} else {
lastParams = params
return
}
Expand All @@ -199,16 +224,17 @@ const paramChangeCallback = async params => {
if(lastParams && lastParams != params) paramChangeCallback(lastParams)
}

const runScript = async ({ script, url = './index.js', base = currentBase, root }) => {
const runScript = async ({ script, url = './jscad.model.js', base = currentBase, root }) => {
currentBase = base
loadDefault = false // don't load default model if something else was loaded
const result = await sendCmdAndSpin('runScript', { script, url, base, root, smooth: viewState.smoothRender })
genParams({ target: byId('paramsDiv'), params: result.def || {}, callback: paramChangeCallback })
handlers.entities(result)
}

const loadExample = (source, base=appBase) => {
editor.setSource(source)
const loadExample = async (source, base = appBase) => {
await resetFileRefs()
editor.setSource(source, base)
runScript({ script: source, base })
}

Expand All @@ -217,32 +243,77 @@ engine.init().then(viewer => {
viewState.setEngine(viewer)
})

editor.init(defaultCode, async (script, path) => {
if (sw && sw.fileToRun) {
await addToCache(sw.cache, path, script)
// imported script will be also cached by require/import implementation
// it is expected if multiple files require same file/module that first time it is loaded
// but for others resolved module is returned
// if not cleared by calling clearFileCache, require will not try to reload the file
await sendCmd('clearFileCache', { files: [path] })
if (sw.fileToRun) runScript({ url: sw.fileToRun, base: sw.base })
} else {
runScript({ script })
let saveMap = {}
setInterval(async ()=>{
for(let p in saveMap){
let handle = saveMap[p]
let file = await handle.getFile()
if(file.lastModified > handle.lastMod){
handle.lastMod = file.lastModified
editor.filesChanged([file])
}
}
})
},500)

editor.init(
defaultCode,
async (script, path) => {
if (sw && sw.fileToRun) {
await addToCache(sw.cache, path, script)
// imported script will be also cached by require/import implementation
// it is expected if multiple files require same file/module that first time it is loaded
// but for others resolved module is returned
// if not cleared by calling clearFileCache, require will not try to reload the file
await sendCmd('clearFileCache', { files: [path] })
if (sw.fileToRun) runScript({ url: sw.fileToRun, base: sw.base })
} else {
runScript({ script })
}
},
async (script, path) => {
console.log('save file', path)
let pathArr = path.split('/')
let fileHandle = (await sw?.getFile(path))?.fileHandle
if(!fileHandle) fileHandle = saveMap[path]
if (!fileHandle) {
const opts = {
suggestedName: pathArr[pathArr.length - 1],
excludeAcceptAllOption: true,
types: [
{
description: 'Javascript',
accept: { 'application/javascript': ['.js'] },
},
],
}
fileHandle = await globalThis.showSaveFilePicker?.(opts)
}
if (fileHandle) {
const writable = await fileHandle.createWritable()
await writable.write(script)
await writable.close()
saveMap[path] = fileHandle
fileHandle.lastMod = Date.now()+500
}
},
path=>sw?.getFile(path)
)
menu.init(loadExample)
welcome.init()
remote.init((script, url) => {
// run remote script
editor.setSource(script)
runScript({ script, base:url })
welcome.dismiss()
}, (err) => {
// show remote script error
loadDefault = false
setError(err)
welcome.dismiss()
})
remote.init(
(script, url) => {
// run remote script
editor.setSource(script, url)
runScript({ script, base: url })
welcome.dismiss()
},
err => {
// show remote script error
loadDefault = false
setError(err)
welcome.dismiss()
},
)
exporter.init(exportModel)

try {
Expand Down
53 changes: 41 additions & 12 deletions apps/jscad-web/src/editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import * as drawer from './drawer.js'
let view

let compileFn
let saveFn
let getFileFn

// file selector
let currentFile = '/index.js'
Expand All @@ -22,14 +24,21 @@ const compile = (code, path) => {
}
}

export const init = (defaultCode, fn) => {
const save = (code, path) => {
compileFn(code, path)
saveFn(code, path)
}

export const init = (defaultCode, fn, _saveFn, _getFileFn) => {
// by calling document.getElementById here instead outside of init we allow the flow
// where javascript is included in the page before the tempalte is loaded into the DOM
// it was causing issue to users trying to replicate the app in Vue, and would likely some others too
editorNav = document.getElementById('editor-nav')
editorFile = document.getElementById('editor-file')

compileFn = fn
saveFn = _saveFn
getFileFn = _getFileFn
// Initialize codemirror
const editorDiv = document.getElementById('editor-container')
view = new EditorView({
Expand All @@ -44,15 +53,15 @@ export const init = (defaultCode, fn) => {
},
{
key: 'Mod-s',
run: () => compile(view.state.doc.toString(), currentFile),
run: () => save(view.state.doc.toString(), currentFile),
preventDefault: true,
},
...defaultKeymap,
]),
],
parent: editorDiv,
})
setSource(defaultCode)
setSource(defaultCode, 'jscad.example.js')

// Initialize drawer action
drawer.init()
Expand All @@ -79,9 +88,36 @@ export const setSource = (source, path = '/index.js') => {
currentFile = path
}

export function filesChanged(files){
let file
files.forEach(async path=>{
if(path == currentFile){
file = await getFileFn(path)
readSource(file, currentFile)
}else if(path.name === currentFile){
let reader = new FileReader()
reader.onloadend = ()=>{
setSource(reader.result, currentFile)
}
reader.readAsText(path)
}
})
}

async function readSource(file, currentFile){
// Read FileEntry
file.file((file) => {
const reader = new FileReader()
reader.onloadend = () => {
setSource(reader.result, currentFile)
}
reader.readAsText(file)
})
}

export const setFiles = (files) => {
const editorFiles = document.getElementById('editor-files')
if (files.length === 1) {
if (files.length < 2) {
editorNav.classList.remove('visible')
} else {
// Update spinner
Expand All @@ -94,14 +130,7 @@ export const setFiles = (files) => {
button.addEventListener('click', () => {
currentFile = file.fsPath
editorFile.innerHTML = currentFile
// Read FileEntry
file.file((file) => {
const reader = new FileReader()
reader.onloadend = () => {
setSource(reader.result, currentFile)
}
reader.readAsText(file)
})
readSource(file, currentFile)
})
item.appendChild(button)
editorFiles.appendChild(item)
Expand Down
Loading

0 comments on commit 02f8612

Please sign in to comment.