diff --git a/.gitignore b/.gitignore index 923bd90..5dd19f1 100644 --- a/.gitignore +++ b/.gitignore @@ -107,4 +107,6 @@ dist .turbo build/** dist/** -**/.history \ No newline at end of file +**/.history +**/.idea + diff --git a/apps/jscad-web/main.js b/apps/jscad-web/main.js index 2ae9bea..a09b06c 100644 --- a/apps/jscad-web/main.js +++ b/apps/jscad-web/main.js @@ -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' @@ -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 => { @@ -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) @@ -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)) @@ -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) @@ -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'), }, @@ -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 } @@ -199,7 +224,7 @@ 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 }) @@ -207,8 +232,9 @@ const runScript = async ({ script, url = './index.js', base = currentBase, root 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 }) } @@ -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 { diff --git a/apps/jscad-web/src/editor.js b/apps/jscad-web/src/editor.js index 57f72fb..2d09e31 100644 --- a/apps/jscad-web/src/editor.js +++ b/apps/jscad-web/src/editor.js @@ -8,6 +8,8 @@ import * as drawer from './drawer.js' let view let compileFn +let saveFn +let getFileFn // file selector let currentFile = '/index.js' @@ -22,7 +24,12 @@ 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 @@ -30,6 +37,8 @@ export const init = (defaultCode, fn) => { editorFile = document.getElementById('editor-file') compileFn = fn + saveFn = _saveFn + getFileFn = _getFileFn // Initialize codemirror const editorDiv = document.getElementById('editor-container') view = new EditorView({ @@ -44,7 +53,7 @@ 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, @@ -52,7 +61,7 @@ export const init = (defaultCode, fn) => { ], parent: editorDiv, }) - setSource(defaultCode) + setSource(defaultCode, 'jscad.example.js') // Initialize drawer action drawer.init() @@ -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 @@ -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) diff --git a/packages/fs-provider/fs-provider.js b/packages/fs-provider/fs-provider.js index 2f2a23e..296a1c2 100644 --- a/packages/fs-provider/fs-provider.js +++ b/packages/fs-provider/fs-provider.js @@ -26,9 +26,14 @@ export function extractPathInfo(url) { let ext = filename.substring(idx + 1) return { url, filename, ext } } + export const getFile = async (path, sw) => { let arr = splitPath(path) - let match = await findFileInRoots(sw.roots, arr) + return await findFileInRoots(sw.roots, arr) +} + +export const getFileContent = async (path, sw) => { + let match = await getFile(path, sw) if (match) { fileIsRequested(path, match, sw) return readAsArrayBuffer(await filePromise(match)) @@ -75,7 +80,7 @@ export const addPreLoad = async (sw, path, ignoreMissing) => { */ export const registerServiceWorker = async ( workerScript, - _getFile = getFile, + _getFile = getFileContent, { prefix = '/swfs/', scope = '/' } = {}, ) => { if ('serviceWorker' in navigator) { @@ -141,7 +146,7 @@ export const clearCache = async cache => { ;(await cache.keys()).forEach(key => cache.delete(key)) } -export const extractEntries = dt => { +export const extractEntries = async dt => { let items = dt.items if (!items) return [] @@ -156,6 +161,8 @@ export const extractEntries = dt => { if (file.webkitGetAsEntry) file = file.webkitGetAsEntry() else if (file.getAsEntry) file = file.getAsEntry() else file = file.webkitGetAsFile() + // we need FileSystemHandle for writing, because old way using createWriter silently ignores writes + if(items[i].getAsFileSystemHandle) file.fileHandle = await items[i].getAsFileSystemHandle() files.push(file) } } diff --git a/packages/fs-provider/src/FileEntry.js b/packages/fs-provider/src/FileEntry.js index acb32f9..c0f201e 100644 --- a/packages/fs-provider/src/FileEntry.js +++ b/packages/fs-provider/src/FileEntry.js @@ -5,13 +5,29 @@ export const readDir = async dir => { let entries = [] let readEntries = await readEntriesPromise(directoryReader) while (readEntries.length > 0) { - entries.push(...readEntries) + for(let i=0; i fileToFsEntry(e, fsDir)) } +async function addFileHandle(dir, entry){ + // we may be reading too fast, wait for the promise to resolve + if(dir.fileHandle.then) await dir.fileHandle + let promise = dir.fileHandle[entry.isDirectory ? 'getDirectoryHandle':'getFileHandle' ](entry.name) + // in case of reading too fast, let those using fileHandle that it is not resolved yet + entry.fileHandle = promise + promise?.then(f=>{ + entry.fileHandle = f + }) +} + export const readEntriesPromise = async directoryReader => cbToPromise(directoryReader, 'readEntries') export const filePromise = async entry =>{ diff --git a/packages/require/src/require.js b/packages/require/src/require.js index 89aa168..1c45f1c 100644 --- a/packages/require/src/require.js +++ b/packages/require/src/require.js @@ -48,7 +48,9 @@ export const require = (urlOrSource, transform, readFile, base, root, importData const resolved = resolveUrl(aliasedUrl, base, root, moduleBase) const resolvedStr = resolved.url.toString() - const isJs = resolvedStr.endsWith('.ts') || resolvedStr.endsWith('.js') + const arr = resolvedStr.split('/') + // no file ext is usually module from CDN + const isJs = !arr[arr.length-1].includes('.') || resolvedStr.endsWith('.ts') || resolvedStr.endsWith('.js') if(!isJs && importData){ const info = extractPathInfo(resolvedStr) let content = readFile(resolvedStr,{output: importData.isBinaryExt(info.ext)})