diff --git a/.gitignore b/.gitignore index 080ec80..3dd3fa0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,6 @@ node_modules libs +dist public/libraries -public/openscad-worker-inlined.js -public/openscad.js -public/openscad.wasm -js -src/*.js -build +src/wasm package-lock.json \ No newline at end of file diff --git a/README.md b/README.md index 396118e..89b8370 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ Should work: Planned: +- Open any file in the VFS. Setup [OPENSCADPATH](https://en.wikibooks.org/wiki/OpenSCAD_User_Manual/Libraries#Setting_OPENSCADPATH) - Customizer support. Probably by adding --export-json or --export-format=customizer-json to OpenSCAD. - Mobile (iOS) editing support: switch to https://www.npmjs.com/package/react-codemirror ? - Preview rendering: have OpenSCAD export the preview scene to a rich format (e.g. glTF, with some parts being translucent when prefixed w/ % modifier) and display it using https://modelviewer.dev/ maybe) diff --git a/public/index.html b/public/index.html index ea06fb5..aee48ee 100644 --- a/public/index.html +++ b/public/index.html @@ -25,17 +25,10 @@ content="OpenSCAD Playground: edit .scad models and render them to STL from the browser (WebAssembly build OpenSCAD, with Monaco editor and STL Viewer)" /> + - OpenSCAD Playground @@ -61,15 +93,5 @@
- diff --git a/src/App.tsx b/src/App.tsx index ffb4a07..4089b00 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,7 +1,7 @@ // Portions of this file are Copyright 2021 Google LLC, and licensed under GPL2+. See COPYING. -import React, { forwardRef, useContext, useEffect, useRef, useState } from 'react'; -import {ModelContext, State} from './app-state' +import React, { CSSProperties, forwardRef, useContext, useEffect, useRef, useState } from 'react'; +import {ModelContext, MultiLayoutComponentId, SingleLayoutComponentId, State} from './app-state' import Editor, { loader, Monaco } from '@monaco-editor/react'; // import './App.css'; import openscadEditorOptions from './language/openscad-editor-options'; @@ -10,6 +10,18 @@ import * as monaco from 'monaco-editor/esm/vs/editor/editor.api'; import {StlViewer} from "react-stl-viewer"; import { Button } from 'primereact/button'; import { ProgressBar } from 'primereact/progressbar'; +import { MenuItem } from 'primereact/menuitem'; +import { TreeSelect } from 'primereact/treeselect'; +import TreeNode from 'primereact/treenode'; +import { TabMenu } from 'primereact/tabmenu'; +import { Badge } from 'primereact/badge'; +import { Menu } from 'primereact/menu'; +import { ToggleButton } from 'primereact/togglebutton'; +import { getParentDir } from './filesystem'; +import { BreadCrumb } from 'primereact/breadcrumb'; +import { ScrollPanel } from 'primereact/scrollpanel'; +import { Splitter, SplitterPanel } from 'primereact/splitter'; +import { Toolbar } from 'primereact/toolbar'; // import "primereact/resources/themes/lara-light-indigo/theme.css"; // import "primereact/resources/primereact.min.css"; @@ -18,17 +30,70 @@ import { ProgressBar } from 'primereact/progressbar'; let monacoInstance: Monaco loader.init().then(mi => monacoInstance = mi); -function EditorPanel() { - const model = useContext(ModelContext); - const [editor, setEditor] = useState(null as monaco.editor.IStandaloneCodeEditor | null) +const isFileWritable = (path: string) => getParentDir(path) === '/home' - if (editor) { - const checkerRun = model.state.lastCheckerRun; - const editorModel = editor.getModel(); - if (editorModel && checkerRun) { - monacoInstance.editor.setModelMarkers(editorModel, 'openscad', checkerRun.markers); +const FSContext = React.createContext(undefined); + +function listFilesAsNodes(fs: FS, path: string): TreeNode[] { + const files: [string, string][] = [] + const dirs: [string, string][] = [] + for (const name of fs.readdirSync(path)) { + const childPath = `${path}/${name}`; + const stat = fs.lstatSync(childPath); + const isDirectory = stat.isDirectory(); + if (!isDirectory && !name.endsWith('.scad')) { + continue; + } + (isDirectory ? dirs : files).push([name, childPath]); + } + [files, dirs].forEach(arr => arr.sort(([a], [b]) => a.localeCompare(b))); + + const nodes: TreeNode[] = [] + for (const [arr, isDirectory] of [[files, false], [dirs, true]] as [[string, string][], boolean][]) { + for (const [name, path] of arr) { + const children = isDirectory ? listFilesAsNodes(fs, path) : undefined; + if (isDirectory && children!.length == 0) { + continue; + } + nodes.push({ + // icon: path == '/home' ? 'pi-home' : ... + icon: isDirectory ? 'pi pi-folder' : isFileWritable(path) ? 'pi pi-file' : 'pi pi-lock', + label: name, + data: path, + key: path, + children, + selectable: !isDirectory // && (name == 'LICENSE' || name.endsWith('.scad') || name.endsWith('.scad') + }); } } + return nodes; +} + +function EditorPanel({className, style}: {className?: string, style?: CSSProperties}) { + + const model = useContext(ModelContext); + if (!model) throw new Error('No model'); + + const state = model.state; + + const fs = useContext(FSContext); + const [editor, setEditor] = useState(null as monaco.editor.IStandaloneCodeEditor | null) + // const [modelFile, setModelFile] = useState(state.params.sourcePath); + + if (editor) { + const checkerRun = state.lastCheckerRun; + const editorModel = editor.getModel(); + if (editorModel) { + if (checkerRun) { + monacoInstance.editor.setModelMarkers(editorModel, 'openscad', checkerRun.markers); + } + // if (modelFile !== state.params.sourcePath) { + // editor.setModel(monaco.editor.createModel('', 'openscad')); + // setModelFile(state.params.sourcePath) + // } + } + } + const onMount = (editor: monaco.editor.IStandaloneCodeEditor) => { editor.addAction({ id: "openscad-render", @@ -48,98 +113,436 @@ function EditorPanel() { setEditor(editor) } + // const breadCrumbsHome: MenuItem = { + // icon: 'pi pi-home' + // } + + // const breadCrumbsItems: MenuItem[] = [ + // { + // label: 'input.scad', + // command: () => alert('ok') + // } + // ] + + const fsItems = fs && listFilesAsNodes(fs, '/home') || []; + + // console.log('getParentDir(', state.params.sourcePath, ')', getParentDir(state.params.sourcePath)) return ( -
- model.source = s ?? ''} - onMount={onMount} // TODO: This looks a bit silly, does it trigger a re-render?? - options={openscadEditorOptions} - height="50vh"/> + {/* */} + model.openFile(String(e.value))} + filter + options={fsItems} /> + + {/* */} + {/*
*/} + {/* */} + {/* */} +
+ model.source = s ?? ''} + onMount={onMount} // TODO: This looks a bit silly, does it trigger a re-render?? + options={{ + ...openscadEditorOptions, + readOnly: !isFileWritable(state.params.sourcePath) + }} + // height="100%" + // width="100%" + // height="80vh" + /> +
+ + {/*
+ */} + {/* */} +
+
{state.lastCheckerRun?.logText ?? 'No log yet!'}
+
+ {/*
*/} + {/*
+
*/} + {/*
+
*/} + {/* */} + {/*
*/} -
-
{model.state.lastCheckerRun?.logText ?? 'No log yet!'}
-
) } +export function ViewerPanel({className, style}: {className?: string, style?: CSSProperties}) { + const model = useContext(ModelContext); + if (!model) throw new Error('No model'); -export function App({initialState}: {initialState: State}) { - const [state, setState] = useState(initialState); - const [editor, setEditor] = useState(null as monaco.editor.IStandaloneCodeEditor | null) + const state = model.state; + + return ( +
+ {state.output?.stlFileURL && + + } + +
+ ) +} + +function downloadOutput(state: State) { + if (!state.output) return; + const a = document.createElement('a') + a.href = state.output.stlFileURL + a.download = state.output.isPreview ? 'preview.stl' : 'render.stl'; + document.body.appendChild(a) + a.click() + document.body.removeChild(a) +} + + +function Footer({style}: {style?: CSSProperties}) { + const model = useContext(ModelContext); + if (!model) throw new Error('No model'); - if (editor) { - const editorModel = editor.getModel(); - if (editorModel && state.lastCheckerRun) { - monacoInstance.editor.setModelMarkers(editorModel, 'openscad', state.lastCheckerRun.markers); + const menu = useRef(null); + + const state = model.state; + + const severityByMarkerSeverity = new Map([ + [monaco.MarkerSeverity.Error, 'danger'], + [monaco.MarkerSeverity.Warning, 'warning'], + [monaco.MarkerSeverity.Info, 'info'], + ]); + const markers = state.lastCheckerRun?.markers ?? []; + const getBadge = (s: monaco.MarkerSeverity) => { + const count = markers.filter(m => m.severity == s).length; + const sev = s == monaco.MarkerSeverity.Error ? 'danger' + : s == monaco.MarkerSeverity.Warning ? 'warning' + : s == monaco.MarkerSeverity.Info ? 'info' + : 'success'; + return <>{count > 0 && }; + }; + + + const maxMarkerSeverity = markers.length == 0 ? undefined : markers.map(m => m.severity).reduce((a, b) => Math.max(a, b)); + + return <> + + +
+ + + {/* {state.output && + + + {state.output.formattedElapsedMillis} + } */} + + {/* {state.previewing && 'previewing... '} + {state.rendering && 'rendering... '} + {state.checkingSyntax && 'checking syntax... '} */} + + {/* */} + + + + {state.output && ( +
+ +} + +export function App({initialState, fs}: {initialState: State, fs: FS}) { + const [state, setState] = useState(initialState); + + const model = new Model(fs, state, setState); + useEffect(() => model.init()); + + // const breadCrumbsItems: MenuItem[] = [ + // { + // label: 'input.scad', + // command: () => alert('ok') + + const singleTargets: {id: SingleLayoutComponentId, icon: string, label: string}[] = [ + { id: 'editor', icon: 'pi pi-pencil', label: 'Edit' }, + { id: 'viewer', icon: 'pi pi-box', label: 'View' }, + { id: 'customizer', icon: 'pi pi-cog', label: 'Customize' }, + ]; + const multiTargets = singleTargets; + // const multiTargets: {id: MultiLayoutComponentId, icon: string, label: string}[] = [ + // { id: 'editor', icon: 'pi pi-pencil', label: 'Editor' }, + // { id: 'viewer', icon: 'pi pi-box', label: 'Viewer' }, + // { id: 'customizer', icon: 'pi pi-cog', label: 'Customizer' }, + // ]; + const zIndexOfPanelsDependingOnFocus = { + editor: { + editor: 3, + viewer: 1, + customizer: 0, + }, + viewer: { + editor: 2, + viewer: 3, + customizer: 2, + }, + customizer: { + editor: 0, + viewer: 0, + customizer: 3, } } - const model = new Model(state, setState); - useEffect(() => model.init()); + const layout = state.view.layout + const mode = state.view.layout.mode; + function getPanelStyle(id: MultiLayoutComponentId): CSSProperties { + if (layout.mode === 'multi') { + const itemCount = (layout.editor ? 1 : 0) + (layout.viewer ? 1 : 0) + (layout.customizer ? 1 : 0) + return { + flex: 1, + maxWidth: Math.floor(100/itemCount) + '%', + display: (state.view.layout as any)[id] ? 'flex' : 'none' + } + } else { + return { + flex: 1, + zIndex: Number((zIndexOfPanelsDependingOnFocus as any)[id][layout.focus]), + } + } + } - // const stlModelRef = forwardRef(); - return ( -
-
-
- -
-
- {(state.rendering || state.previewing) && - } - {state.output?.stlFileURL && - +
+
- }} - url={state.output?.stlFileURL ?? ''} - /> - } - -
-
-
- - {state.output && state.output.formattedElapsedMillis} -   - {state.previewing && 'previewing... '} - {state.rendering && 'rendering... '} - {state.checkingSyntax && 'checking syntax... '} - - {state.output && ( - - {state.output.isPreview ? "preview.stl" : "render.stl"} ({state.output.formattedStlFileSize}) - - )} - - - Docs | - Cheatsheet | - LICENSES + {/*

+ OpenSCAD +

*/} +
+ + {/* */} + + {state.view.layout.mode === 'multi' + ?
+ {multiTargets.map(({icon, label, id}) => + //
+ : + ({icon, label, command: () => model.changeSingleVisibility(id)}))} /> + } +
+
+ +
+ {/* {position: 'relative'}}> */} + + + + {/* */} +
+ +
-
+ ); } + + // + // + // + // + // + // + // + // + //