Gooood progress
This commit is contained in:
parent
f996bbc1fb
commit
6528baa7b6
17 changed files with 752 additions and 171 deletions
8
.gitignore
vendored
8
.gitignore
vendored
|
|
@ -1,10 +1,6 @@
|
||||||
node_modules
|
node_modules
|
||||||
libs
|
libs
|
||||||
|
dist
|
||||||
public/libraries
|
public/libraries
|
||||||
public/openscad-worker-inlined.js
|
src/wasm
|
||||||
public/openscad.js
|
|
||||||
public/openscad.wasm
|
|
||||||
js
|
|
||||||
src/*.js
|
|
||||||
build
|
|
||||||
package-lock.json
|
package-lock.json
|
||||||
|
|
@ -18,6 +18,7 @@ Should work:
|
||||||
|
|
||||||
Planned:
|
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.
|
- 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 ?
|
- 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)
|
- 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)
|
||||||
|
|
|
||||||
|
|
@ -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)"
|
content="OpenSCAD Playground: edit .scad models and render them to STL from the browser (WebAssembly build OpenSCAD, with Monaco editor and STL Viewer)"
|
||||||
/>
|
/>
|
||||||
<link rel="apple-touch-icon" href="logo192.png" />
|
<link rel="apple-touch-icon" href="logo192.png" />
|
||||||
|
|
||||||
<!-- https://developers.google.com/web/fundamentals/web-app-manifest/ -->
|
<!-- https://developers.google.com/web/fundamentals/web-app-manifest/ -->
|
||||||
<link rel="manifest" href="manifest.json" />
|
<link rel="manifest" href="manifest.json" />
|
||||||
<!--
|
|
||||||
Notice the use of %PUBLIC_URL% in the tags above.
|
|
||||||
It will be replaced with the URL of the `public` folder during the build.
|
|
||||||
Only files inside the `public` folder can be referenced from the HTML.
|
|
||||||
|
|
||||||
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
|
||||||
work correctly both with client-side routing and a non-root public URL.
|
|
||||||
Learn how to configure a non-root public URL by running `npm run build`.
|
|
||||||
-->
|
|
||||||
<title>OpenSCAD Playground</title>
|
<title>OpenSCAD Playground</title>
|
||||||
<style>
|
<style>
|
||||||
#root,
|
#root,
|
||||||
|
|
@ -47,6 +40,45 @@
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.p-tabmenu-nav {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.absolute-fill {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.monaco-editor {
|
||||||
|
background-color: rgba(255,255,255,0.5) !important;
|
||||||
|
}
|
||||||
|
.overflow-guard,
|
||||||
|
.monaco-scrollable-element,
|
||||||
|
.monaco-editor-background {
|
||||||
|
background-color: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opacity-animated {
|
||||||
|
transition: opacity 0.5s ease-in-out;
|
||||||
|
}
|
||||||
|
.opacity-0 {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media only screen and (max-height: 600px),
|
||||||
|
only screen and (max-width: 600px),
|
||||||
|
only screen and (max-width: 767px),
|
||||||
|
only screen and (max-aspect-ratio: 1/2) {
|
||||||
|
.github-fork-ribbon {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
<style>.github-fork-ribbon:before { background-color: #333; }</style>
|
<style>.github-fork-ribbon:before { background-color: #333; }</style>
|
||||||
|
|
||||||
|
|
@ -61,15 +93,5 @@
|
||||||
|
|
||||||
<noscript>You need to enable JavaScript to run the OpenSCAD Playground.</noscript>
|
<noscript>You need to enable JavaScript to run the OpenSCAD Playground.</noscript>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<!--
|
|
||||||
This HTML file is a template.
|
|
||||||
If you open it directly in the browser, you will see an empty page.
|
|
||||||
|
|
||||||
You can add webfonts, meta tags, or analytics to this file.
|
|
||||||
The build step will place the bundled scripts into the <body> tag.
|
|
||||||
|
|
||||||
To begin the development, run `npm start` or `yarn start`.
|
|
||||||
To create a production bundle, use `npm run build` or `yarn build`.
|
|
||||||
-->
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
517
src/App.tsx
517
src/App.tsx
|
|
@ -1,7 +1,7 @@
|
||||||
// Portions of this file are Copyright 2021 Google LLC, and licensed under GPL2+. See COPYING.
|
// 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 React, { CSSProperties, forwardRef, useContext, useEffect, useRef, useState } from 'react';
|
||||||
import {ModelContext, State} from './app-state'
|
import {ModelContext, MultiLayoutComponentId, SingleLayoutComponentId, State} from './app-state'
|
||||||
import Editor, { loader, Monaco } from '@monaco-editor/react';
|
import Editor, { loader, Monaco } from '@monaco-editor/react';
|
||||||
// import './App.css';
|
// import './App.css';
|
||||||
import openscadEditorOptions from './language/openscad-editor-options';
|
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 {StlViewer} from "react-stl-viewer";
|
||||||
import { Button } from 'primereact/button';
|
import { Button } from 'primereact/button';
|
||||||
import { ProgressBar } from 'primereact/progressbar';
|
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/themes/lara-light-indigo/theme.css";
|
||||||
// import "primereact/resources/primereact.min.css";
|
// import "primereact/resources/primereact.min.css";
|
||||||
|
|
@ -18,17 +30,70 @@ import { ProgressBar } from 'primereact/progressbar';
|
||||||
let monacoInstance: Monaco
|
let monacoInstance: Monaco
|
||||||
loader.init().then(mi => monacoInstance = mi);
|
loader.init().then(mi => monacoInstance = mi);
|
||||||
|
|
||||||
function EditorPanel() {
|
const isFileWritable = (path: string) => getParentDir(path) === '/home'
|
||||||
|
|
||||||
|
const FSContext = React.createContext<FS | undefined>(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);
|
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 [editor, setEditor] = useState(null as monaco.editor.IStandaloneCodeEditor | null)
|
||||||
|
// const [modelFile, setModelFile] = useState(state.params.sourcePath);
|
||||||
|
|
||||||
if (editor) {
|
if (editor) {
|
||||||
const checkerRun = model.state.lastCheckerRun;
|
const checkerRun = state.lastCheckerRun;
|
||||||
const editorModel = editor.getModel();
|
const editorModel = editor.getModel();
|
||||||
if (editorModel && checkerRun) {
|
if (editorModel) {
|
||||||
|
if (checkerRun) {
|
||||||
monacoInstance.editor.setModelMarkers(editorModel, 'openscad', checkerRun.markers);
|
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) => {
|
const onMount = (editor: monaco.editor.IStandaloneCodeEditor) => {
|
||||||
editor.addAction({
|
editor.addAction({
|
||||||
id: "openscad-render",
|
id: "openscad-render",
|
||||||
|
|
@ -48,59 +113,116 @@ function EditorPanel() {
|
||||||
setEditor(editor)
|
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 (
|
return (
|
||||||
<div className="editor-panel" style={{
|
<div className={`editor-panel ${className ?? ''}`} style={{
|
||||||
|
// maxWidth: '5 0vw',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
position: 'relative'
|
// position: 'relative',
|
||||||
|
// width: '100%', height: '100%',
|
||||||
|
...(style ?? {})
|
||||||
|
}}>
|
||||||
|
{/* <BreadCrumb model={breadCrumbsItems} home={breadCrumbsHome}/> */}
|
||||||
|
<TreeSelect
|
||||||
|
title='OpenSCAD Playground Files'
|
||||||
|
value={state.params.sourcePath}
|
||||||
|
onChange={(e) => model.openFile(String(e.value))}
|
||||||
|
filter
|
||||||
|
options={fsItems} />
|
||||||
|
|
||||||
|
{/* <SplitPane type="vertical"> */}
|
||||||
|
{/* <div className='flex flex-column'> */}
|
||||||
|
{/* <Splitter style={{
|
||||||
|
// height: '300px'
|
||||||
|
}} layout="vertical"> */}
|
||||||
|
{/* <SplitterPanel className="flex flex-column align-items-center justify-content-center"
|
||||||
|
style={{
|
||||||
|
// width: '100%', height: '100%'
|
||||||
|
}}> */}
|
||||||
|
<div style={{
|
||||||
|
position: 'relative',
|
||||||
|
flex: 1
|
||||||
}}>
|
}}>
|
||||||
<Editor
|
<Editor
|
||||||
className="openscad-editor"
|
className="openscad-editor absolute-fill"
|
||||||
defaultLanguage="openscad"
|
defaultLanguage="openscad"
|
||||||
value={model.state.params.source}
|
path={state.params.sourcePath}
|
||||||
|
value={state.params.source}
|
||||||
onChange={s => model.source = s ?? ''}
|
onChange={s => model.source = s ?? ''}
|
||||||
onMount={onMount} // TODO: This looks a bit silly, does it trigger a re-render??
|
onMount={onMount} // TODO: This looks a bit silly, does it trigger a re-render??
|
||||||
options={openscadEditorOptions}
|
options={{
|
||||||
height="50vh"/>
|
...openscadEditorOptions,
|
||||||
|
readOnly: !isFileWritable(state.params.sourcePath)
|
||||||
<div className="logs-container">
|
}}
|
||||||
<pre><code id="logs">{model.state.lastCheckerRun?.logText ?? 'No log yet!'}</code></pre>
|
// height="100%"
|
||||||
|
// width="100%"
|
||||||
|
// height="80vh"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* </SplitterPanel>
|
||||||
|
<SplitterPanel className="flex flex-column align-items-center justify-content-center" style={{position: 'relative'}}> */}
|
||||||
|
{/* <ScrollPanel style={{
|
||||||
|
// width: '100%', height: '200px'
|
||||||
|
}} className="custombar2"> */}
|
||||||
|
<div style={{
|
||||||
|
display: state.view.logs ? undefined : 'none',
|
||||||
|
overflowY: 'scroll',
|
||||||
|
height: 'calc(min(200px, 30vh))',
|
||||||
|
// position: 'relative',
|
||||||
|
// maxWidth: '100%',
|
||||||
|
// maxWidth: '50vw',
|
||||||
|
}}>
|
||||||
|
<pre><code id="logs" style={{
|
||||||
|
// maxWidth: '200px'
|
||||||
|
}}>{state.lastCheckerRun?.logText ?? 'No log yet!'}</code></pre>
|
||||||
|
</div>
|
||||||
|
{/* </ScrollPanel> */}
|
||||||
|
{/* <div className="logs-container">
|
||||||
|
</div> */}
|
||||||
|
{/* </SplitterPanel>
|
||||||
|
</Splitter> */}
|
||||||
|
{/* </SplitPane> */}
|
||||||
|
{/* </div> */}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 = model.state;
|
||||||
const [state, setState] = useState(initialState);
|
|
||||||
const [editor, setEditor] = useState(null as monaco.editor.IStandaloneCodeEditor | null)
|
|
||||||
|
|
||||||
if (editor) {
|
|
||||||
const editorModel = editor.getModel();
|
|
||||||
if (editorModel && state.lastCheckerRun) {
|
|
||||||
monacoInstance.editor.setModelMarkers(editorModel, 'openscad', state.lastCheckerRun.markers);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const model = new Model(state, setState);
|
|
||||||
useEffect(() => model.init());
|
|
||||||
|
|
||||||
// const stlModelRef = forwardRef();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ModelContext.Provider value={model}>
|
<div className={className}
|
||||||
<div style={{display: 'flex', flexDirection: 'column', flex: 1}}>
|
style={{display: 'flex', flexDirection: 'column',
|
||||||
<div style={{display: 'flex', flexDirection: 'row', flex: 1}}>
|
flex: 1,
|
||||||
<div style={{width: "50vw"}}>
|
position: 'relative',
|
||||||
<EditorPanel/>
|
width: '100%',
|
||||||
</div>
|
height: '100%',
|
||||||
<div style={{display: 'flex', flexDirection: 'column', flex: 1, position: 'relative'}}>
|
...(style ?? {})
|
||||||
{(state.rendering || state.previewing) &&
|
}}>
|
||||||
<ProgressBar mode="indeterminate" style={{ height: '6px' }}></ProgressBar>}
|
|
||||||
{state.output?.stlFileURL &&
|
{state.output?.stlFileURL &&
|
||||||
<StlViewer
|
<StlViewer
|
||||||
style={{
|
style={{
|
||||||
flex: 1
|
flex: 1
|
||||||
|
// width: '100%'
|
||||||
}}
|
}}
|
||||||
// ref={stlModelRef}
|
// ref={stlModelRef}
|
||||||
showAxes={true}
|
showAxes={true}
|
||||||
|
|
@ -115,31 +237,312 @@ export function App({initialState}: {initialState: State}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)
|
||||||
<div style={{display: 'flex', flexDirection: 'row', gap: '10px'}}>
|
}
|
||||||
<Button onClick={() => model.render({isPreview: false, now: true})}>Render</Button>
|
|
||||||
{state.output && state.output.formattedElapsedMillis}
|
|
||||||
|
|
||||||
{state.previewing && 'previewing... '}
|
|
||||||
{state.rendering && 'rendering... '}
|
|
||||||
{state.checkingSyntax && 'checking syntax... '}
|
|
||||||
|
|
||||||
{state.output && (
|
function downloadOutput(state: State) {
|
||||||
<a href={state.output.stlFileURL}
|
if (!state.output) return;
|
||||||
target="_blank"
|
const a = document.createElement('a')
|
||||||
download={state.output.isPreview ? "preview.stl" : "render.stl"}
|
a.href = state.output.stlFileURL
|
||||||
title="STL Download">
|
a.download = state.output.isPreview ? 'preview.stl' : 'render.stl';
|
||||||
{state.output.isPreview ? "preview.stl" : "render.stl"} ({state.output.formattedStlFileSize})
|
document.body.appendChild(a)
|
||||||
</a>
|
a.click()
|
||||||
)}
|
document.body.removeChild(a)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function Footer({style}: {style?: CSSProperties}) {
|
||||||
|
const model = useContext(ModelContext);
|
||||||
|
if (!model) throw new Error('No model');
|
||||||
|
|
||||||
|
const menu = useRef<Menu>(null);
|
||||||
|
|
||||||
|
const state = model.state;
|
||||||
|
|
||||||
|
const severityByMarkerSeverity = new Map<monaco.MarkerSeverity, 'danger' | 'warning' | 'info'>([
|
||||||
|
[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 && <Badge value={count} severity={severityByMarkerSeverity.get(s)}></Badge>}</>;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const maxMarkerSeverity = markers.length == 0 ? undefined : markers.map(m => m.severity).reduce((a, b) => Math.max(a, b));
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<ProgressBar mode="indeterminate"
|
||||||
|
style={{
|
||||||
|
marginLeft: '5px',
|
||||||
|
marginRight: '5px',
|
||||||
|
visibility: state.rendering || state.previewing || state.checkingSyntax
|
||||||
|
? 'visible' : 'hidden',
|
||||||
|
height: '6px' }}></ProgressBar>
|
||||||
|
|
||||||
|
<div className="flex flex-row gap-1" style={{
|
||||||
|
// display: 'flex',
|
||||||
|
// flexDirection: 'row',
|
||||||
|
// gap: '10px',
|
||||||
|
// verticalAlign: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
margin: '5px',
|
||||||
|
...(style ?? {})
|
||||||
|
}}>
|
||||||
|
<Button onClick={() => model.render({isPreview: false, now: true})}
|
||||||
|
loading={state.rendering}
|
||||||
|
icon="pi pi-refresh"
|
||||||
|
label="Render" />
|
||||||
|
|
||||||
|
{/* {state.error != null &&
|
||||||
|
<i className="pi pi-exclamation-circle" style={{color: 'red'}} title={state.error}></i>} */}
|
||||||
|
|
||||||
|
<Button type="button"
|
||||||
|
// label={state.view.logs ? "Hide logs" : "Show logs"}
|
||||||
|
label="Logs"
|
||||||
|
icon="pi pi-align-left"
|
||||||
|
text={!state.view.logs}
|
||||||
|
onClick={() => model.logsVisible = !state.view.logs}
|
||||||
|
// onLabel="Logs" offLabel="Logs"
|
||||||
|
// onIcon="pi pi-align-left"
|
||||||
|
// offIcon="pi pi-align-left"
|
||||||
|
className={maxMarkerSeverity && `p-button-${severityByMarkerSeverity.get(maxMarkerSeverity) ?? 'success'}`}
|
||||||
|
>
|
||||||
|
{getBadge(monaco.MarkerSeverity.Error)}
|
||||||
|
{getBadge(monaco.MarkerSeverity.Warning)}
|
||||||
|
{getBadge(monaco.MarkerSeverity.Info)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* {state.output &&
|
||||||
|
<span style={{color: 'blue'}}>
|
||||||
|
<i className="pi pi-stopwatch" title={state.error}></i>
|
||||||
|
<span style={{margin: '5px'}}>{state.output.formattedElapsedMillis}</span>
|
||||||
|
</span>} */}
|
||||||
|
|
||||||
|
{/* {state.previewing && 'previewing... '}
|
||||||
|
{state.rendering && 'rendering... '}
|
||||||
|
{state.checkingSyntax && 'checking syntax... '} */}
|
||||||
|
|
||||||
|
{/* <span style={{flex: 1}}></span> */}
|
||||||
|
|
||||||
<span style={{flex: 1}}></span>
|
<span style={{flex: 1}}></span>
|
||||||
|
|
||||||
<a target="_blank" className="text-fragment" href="http://openscad.org/documentation.html">Docs</a> |
|
{state.output && (
|
||||||
<a target="_blank" className="text-fragment" href="http://openscad.org/cheatsheet/">Cheatsheet</a> |
|
<Button icon='pi pi-download'
|
||||||
<a target="_blank" href="https://github.com/openscad/openscad-playground/blob/rewrite1/LICENSE.md">LICENSES</a>
|
title={`Download ${state.output.isPreview ? "preview.stl" : "render.stl"} (${state.output.formattedStlFileSize})`}
|
||||||
|
severity="secondary"
|
||||||
|
text
|
||||||
|
// label={state.output.isPreview ? "preview.stl" : "render.stl"}
|
||||||
|
iconPos='right'
|
||||||
|
onClick={() => downloadOutput(state)} />
|
||||||
|
// <a href={state.output.stlFileURL}
|
||||||
|
// target="_blank"
|
||||||
|
// download={state.output.isPreview ? "preview.stl" : "render.stl"}
|
||||||
|
// title="STL Download">
|
||||||
|
// {state.output.isPreview ? "preview.stl" : "render.stl"} ({state.output.formattedStlFileSize})
|
||||||
|
// </a>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ToggleButton
|
||||||
|
style={{
|
||||||
|
// flex: 1,
|
||||||
|
}}
|
||||||
|
onIcon='pi pi-table'
|
||||||
|
offIcon='pi pi-table'
|
||||||
|
onLabel=''
|
||||||
|
offLabel=''
|
||||||
|
// onLabel='Multi'
|
||||||
|
// offLabel='Single'
|
||||||
|
title='Change between single and multiple layout'
|
||||||
|
checked={state.view.layout.mode === 'multi'}
|
||||||
|
onChange={e => model.changeLayout(e.value ? 'multi' : 'single')} />
|
||||||
|
|
||||||
|
<Menu model={[
|
||||||
|
{
|
||||||
|
label: "openscad-playground",
|
||||||
|
icon: 'pi pi-github',
|
||||||
|
command: () => window.open('https://github.com/openscad/openscad-playground/tree/rewrite1', '_blank'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'LICENSES',
|
||||||
|
icon: 'pi pi-info-circle',
|
||||||
|
command: () => window.open('https://github.com/openscad/openscad-playground/blob/rewrite1/LICENSE.md', '_blank'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'OpenSCAD Docs',
|
||||||
|
icon: 'pi pi-book',
|
||||||
|
command: () => window.open('http://openscad.org/documentation.html', '_blank'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'OpenSCAD Cheatsheet',
|
||||||
|
icon: 'pi pi-palette',
|
||||||
|
command: () => window.open('http://openscad.org/cheatsheet/', '_blank'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'BOSL2 Cheatsheet',
|
||||||
|
icon: 'pi pi-palette',
|
||||||
|
command: () => window.open('https://github.com/revarbat/BOSL2/wiki/CheatSheet', '_blank'),
|
||||||
|
},
|
||||||
|
] as MenuItem[]} popup ref={menu} />
|
||||||
|
<Button title="Help & Licenses" rounded icon="pi pi-question-circle" onClick={(e) => menu.current && menu.current.toggle(e)} />
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModelContext.Provider value={model}>
|
||||||
|
<FSContext.Provider value={fs}>
|
||||||
|
<div className='flex flex-column' style={{height: '100vh'}}>
|
||||||
|
<div className=""
|
||||||
|
// style={{
|
||||||
|
// backgroundImage: 'url("logo192.png")',
|
||||||
|
// backgroundPositionX: 'left',
|
||||||
|
// backgroundPositionY: 'top',
|
||||||
|
// backgroundRepeat: 'no-repeat',
|
||||||
|
// }}
|
||||||
|
>
|
||||||
|
|
||||||
|
|
||||||
|
{/* <h1>
|
||||||
|
OpenSCAD
|
||||||
|
</h1> */}
|
||||||
|
<div className='flex flex-row' style={{
|
||||||
|
// justifyContent: 'center',
|
||||||
|
margin: '5px',
|
||||||
|
// marginTop: '70px',
|
||||||
|
}}>
|
||||||
|
|
||||||
|
{/* <span style={{flex: 1}} /> */}
|
||||||
|
|
||||||
|
{state.view.layout.mode === 'multi'
|
||||||
|
? <div className='flex flex-row gap-1' style={{
|
||||||
|
justifyContent: 'center',
|
||||||
|
flex: 1,
|
||||||
|
margin: '5px'
|
||||||
|
}}>
|
||||||
|
{multiTargets.map(({icon, label, id}) =>
|
||||||
|
// <Button
|
||||||
|
<ToggleButton
|
||||||
|
key={id}
|
||||||
|
// raised={(state.view.layout as any)[id]}
|
||||||
|
checked={(state.view.layout as any)[id]}
|
||||||
|
// label={label}
|
||||||
|
onLabel={label}
|
||||||
|
offLabel={label}
|
||||||
|
onIcon={icon}
|
||||||
|
offIcon={icon}
|
||||||
|
// icon={icon}
|
||||||
|
disabled={id === 'customizer'}
|
||||||
|
// onClick={() => model.changeMultiVisibility(id, !(state.view.layout as any)[id])}
|
||||||
|
onChange={e => model.changeMultiVisibility(id, e.value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
: <TabMenu
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
// justifyContent: 'center'
|
||||||
|
}}
|
||||||
|
model={singleTargets.map(({icon, label, id}) =>
|
||||||
|
({icon, label, command: () => model.changeSingleVisibility(id)}))} />
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className={mode === 'multi' ? 'flex flex-row' : 'flex flex-column'}
|
||||||
|
style={mode === 'multi' ? {flex: 1} : {
|
||||||
|
flex: 1,
|
||||||
|
position: 'relative'
|
||||||
|
}}>
|
||||||
|
{/* {position: 'relative'}}> */}
|
||||||
|
|
||||||
|
<EditorPanel className={`
|
||||||
|
opacity-animated
|
||||||
|
${layout.mode === 'single' && layout.focus !== 'editor' ? 'opacity-0' : ''}
|
||||||
|
${layout.mode === 'single' ? 'absolute-fill' : ''}
|
||||||
|
`} style={getPanelStyle('editor')} />
|
||||||
|
<ViewerPanel className={layout.mode === 'single' ? `absolute-fill` : ''} style={getPanelStyle('viewer')} />
|
||||||
|
{/* <CustomizerPanel className={`${getPanelClasses('customizer')} absolute-fill`} style={getPanelStyle('customizer')} /> */}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
</FSContext.Provider>
|
||||||
</ModelContext.Provider>
|
</ModelContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// <Splitter style={{ flex: 1 }}>
|
||||||
|
// <SplitterPanel className="flex flex-column align-items-center justify-content-center">
|
||||||
|
// <EditorPanel />
|
||||||
|
// </SplitterPanel>
|
||||||
|
// <SplitterPanel className="flex flex-column align-items-center justify-content-center">
|
||||||
|
// <ViewerPanel/>
|
||||||
|
// </SplitterPanel>
|
||||||
|
// </Splitter>
|
||||||
|
// <Footer />
|
||||||
3
src/BrowserFS.d.ts
vendored
3
src/BrowserFS.d.ts
vendored
|
|
@ -3,9 +3,12 @@
|
||||||
// import { FS } from "./filesystem";
|
// import { FS } from "./filesystem";
|
||||||
|
|
||||||
declare interface FS {
|
declare interface FS {
|
||||||
|
writeFile(path: string, content: string): void;
|
||||||
readdir(path: string, cb: (err: any, files: string[]) => void): void;
|
readdir(path: string, cb: (err: any, files: string[]) => void): void;
|
||||||
|
readdirSync(path: string): string[];
|
||||||
symlink(target: string, source: string): void;
|
symlink(target: string, source: string): void;
|
||||||
readFileSync(path: string): BufferSource;
|
readFileSync(path: string): BufferSource;
|
||||||
|
lstatSync(path: string): {isDirectory(): boolean};
|
||||||
}
|
}
|
||||||
|
|
||||||
declare interface EmscriptenFS extends FS {}
|
declare interface EmscriptenFS extends FS {}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
// Portions of this file are Copyright 2021 Google LLC, and licensed under GPL2+. See COPYING.
|
// Portions of this file are Copyright 2021 Google LLC, and licensed under GPL2+. See COPYING.
|
||||||
|
|
||||||
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
|
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
|
||||||
|
import { getParentDir } from './filesystem';
|
||||||
import { spawnOpenSCAD } from "./openscad-runner";
|
import { spawnOpenSCAD } from "./openscad-runner";
|
||||||
import { processMergedOutputs } from "./output-parser";
|
import { processMergedOutputs } from "./output-parser";
|
||||||
import { AbortablePromise, turnIntoDelayableExecution } from './utils';
|
import { AbortablePromise, turnIntoDelayableExecution } from './utils';
|
||||||
|
|
@ -9,15 +10,15 @@ const syntaxDelay = 300;
|
||||||
|
|
||||||
type SyntaxCheckOutput = {logText: string, markers: monaco.editor.IMarkerData[]};
|
type SyntaxCheckOutput = {logText: string, markers: monaco.editor.IMarkerData[]};
|
||||||
export const checkSyntax =
|
export const checkSyntax =
|
||||||
turnIntoDelayableExecution(syntaxDelay, (source: string) => {
|
turnIntoDelayableExecution(syntaxDelay, (source: string, sourcePath: string) => {
|
||||||
// const timestamp = Date.now();
|
// const timestamp = Date.now();
|
||||||
|
|
||||||
source = '$preview=true;\n' + source;
|
source = '$preview=true;\n' + source;
|
||||||
const sourceFile = 'input.scad';
|
|
||||||
|
|
||||||
const job = spawnOpenSCAD({
|
const job = spawnOpenSCAD({
|
||||||
inputs: [[sourceFile, source + '\n']],
|
workingDir: getParentDir(sourcePath),
|
||||||
args: [sourceFile, "-o", "out.ast"],
|
inputs: [[sourcePath, source + '\n']],
|
||||||
|
args: [sourcePath, "-o", "out.ast"],
|
||||||
});
|
});
|
||||||
|
|
||||||
return AbortablePromise<SyntaxCheckOutput>((res, rej) => {
|
return AbortablePromise<SyntaxCheckOutput>((res, rej) => {
|
||||||
|
|
@ -25,7 +26,7 @@ export const checkSyntax =
|
||||||
try {
|
try {
|
||||||
const result = await job;
|
const result = await job;
|
||||||
// console.log(result);
|
// console.log(result);
|
||||||
res(processMergedOutputs(result.mergedOutputs, {shiftSourceLines: {[sourceFile]: 1}}));
|
res(processMergedOutputs(result.mergedOutputs, {shiftSourceLines: {[sourcePath]: 1}}));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
rej(e);
|
rej(e);
|
||||||
|
|
@ -40,15 +41,15 @@ export type RenderOutput = {stlFile: File, logText: string, markers: monaco.edit
|
||||||
|
|
||||||
export type RenderArgs = {
|
export type RenderArgs = {
|
||||||
source: string,
|
source: string,
|
||||||
|
sourcePath: string,
|
||||||
features?: string[],
|
features?: string[],
|
||||||
extraArgs?: string[],
|
extraArgs?: string[],
|
||||||
isPreview: boolean
|
isPreview: boolean
|
||||||
}
|
}
|
||||||
export const render =
|
export const render =
|
||||||
turnIntoDelayableExecution(renderDelay, (params: RenderArgs) => {
|
turnIntoDelayableExecution(renderDelay, (params: RenderArgs) => {
|
||||||
const inputFile = 'input.scad';
|
|
||||||
const args = [
|
const args = [
|
||||||
inputFile,
|
params.sourcePath,
|
||||||
"-o", "out.stl",
|
"-o", "out.stl",
|
||||||
"--export-format=binstl",
|
"--export-format=binstl",
|
||||||
...(params.features ?? []).map(f => `--enable=${f}`),
|
...(params.features ?? []).map(f => `--enable=${f}`),
|
||||||
|
|
@ -63,7 +64,8 @@ export const render =
|
||||||
|
|
||||||
const job = spawnOpenSCAD({
|
const job = spawnOpenSCAD({
|
||||||
// wasmMemory,
|
// wasmMemory,
|
||||||
inputs: [['input.scad', source]],
|
workingDir: getParentDir(params.sourcePath),
|
||||||
|
inputs: [[params.sourcePath, source]],
|
||||||
args,
|
args,
|
||||||
outputPaths: ['out.stl']
|
outputPaths: ['out.stl']
|
||||||
});
|
});
|
||||||
|
|
@ -75,7 +77,7 @@ export const render =
|
||||||
console.log(result);
|
console.log(result);
|
||||||
|
|
||||||
const {logText, markers} = processMergedOutputs(result.mergedOutputs, {
|
const {logText, markers} = processMergedOutputs(result.mergedOutputs, {
|
||||||
shiftSourceLines: {[inputFile]: prefixLines.length}
|
shiftSourceLines: {[params.sourcePath]: prefixLines.length}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
|
|
@ -83,7 +85,10 @@ export const render =
|
||||||
}
|
}
|
||||||
|
|
||||||
const [output] = result.outputs ?? [];
|
const [output] = result.outputs ?? [];
|
||||||
if (!output) throw 'No output from runner!'
|
if (!output) {
|
||||||
|
reject(new Error('No output from runner!'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
const [filePath, content] = output;
|
const [filePath, content] = output;
|
||||||
const filePathFragments = filePath.split('/');
|
const filePathFragments = filePath.split('/');
|
||||||
const fileName = filePathFragments[filePathFragments.length - 1];
|
const fileName = filePathFragments[filePathFragments.length - 1];
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,26 @@ import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Model } from "./model"
|
import { Model } from "./model"
|
||||||
|
|
||||||
|
export type MultiLayoutComponentId = 'editor' | 'viewer' | 'customizer';
|
||||||
|
export type SingleLayoutComponentId = MultiLayoutComponentId;
|
||||||
|
|
||||||
export interface State {
|
export interface State {
|
||||||
params: {
|
params: {
|
||||||
|
sourcePath: string,
|
||||||
source: string,
|
source: string,
|
||||||
features: string[],
|
features: string[],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
view: {
|
||||||
|
logs?: boolean,
|
||||||
|
layout: {
|
||||||
|
mode: 'single',
|
||||||
|
focus: SingleLayoutComponentId,
|
||||||
|
} | ({
|
||||||
|
mode: 'multi',
|
||||||
|
} & { [K in MultiLayoutComponentId]: boolean })
|
||||||
|
}
|
||||||
|
|
||||||
lastCheckerRun?: {
|
lastCheckerRun?: {
|
||||||
logText: string,
|
logText: string,
|
||||||
markers: monaco.editor.IMarkerData[]
|
markers: monaco.editor.IMarkerData[]
|
||||||
|
|
@ -18,6 +33,7 @@ export interface State {
|
||||||
previewing?: boolean,
|
previewing?: boolean,
|
||||||
checkingSyntax?: boolean,
|
checkingSyntax?: boolean,
|
||||||
|
|
||||||
|
error?: string,
|
||||||
output?: {
|
output?: {
|
||||||
isPreview: boolean,
|
isPreview: boolean,
|
||||||
stlFile: File,
|
stlFile: File,
|
||||||
|
|
@ -28,12 +44,8 @@ export interface State {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ModelContext = React.createContext(new Model(
|
export const ModelContext = React.createContext<Model | null>(null);
|
||||||
{
|
|
||||||
params: {
|
|
||||||
source: '',
|
|
||||||
features: ['manifold', 'lazy-union'],
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
() => { throw new Error('Not implemented'); }
|
() => { throw new Error('Not implemented'); }
|
||||||
));
|
));
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,9 @@ function collectObjectEntriesDeeply(o: KVObject, out: KVEntriesMap = new Map()):
|
||||||
}
|
}
|
||||||
|
|
||||||
function bubbleChangesUp(o: KVObject, allOriginalEntries: KVEntriesMap) {
|
function bubbleChangesUp(o: KVObject, allOriginalEntries: KVEntriesMap) {
|
||||||
|
if (o == null || typeof o !== 'object') {
|
||||||
|
return o;
|
||||||
|
}
|
||||||
const entries = Object.entries(o);
|
const entries = Object.entries(o);
|
||||||
const originalEntries = allOriginalEntries.get(o);
|
const originalEntries = allOriginalEntries.get(o);
|
||||||
if (!originalEntries) {
|
if (!originalEntries) {
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ export type FSMounts = {
|
||||||
|
|
||||||
export type Symlinks = {[alias: string]: string};
|
export type Symlinks = {[alias: string]: string};
|
||||||
|
|
||||||
|
export const getParentDir = (path: string) => path.split('/').slice(0, -1).join('/');
|
||||||
|
|
||||||
export function readDirAsArray(fs: FS, path: string): Promise<string[] | undefined> {
|
export function readDirAsArray(fs: FS, path: string): Promise<string[] | undefined> {
|
||||||
return new Promise((res, rej) => fs.readdir(path, (err, files) => err ? rej(err) : res(files)));
|
return new Promise((res, rej) => fs.readdir(path, (err, files) => err ? rej(err) : res(files)));
|
||||||
}
|
}
|
||||||
|
|
@ -70,7 +72,7 @@ function configureAndInstallFS(windowOrSelf: Window, options: any) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createEditorFS(workingDir='/home') {
|
export async function createEditorFS(workingDir='/home'): Promise<FS> {
|
||||||
const archiveNames = Object.keys(zipArchives);
|
const archiveNames = Object.keys(zipArchives);
|
||||||
const librariesMounts = await getBrowserFSLibrariesMounts(archiveNames);
|
const librariesMounts = await getBrowserFSLibrariesMounts(archiveNames);
|
||||||
const allMounts: FSMounts = {};
|
const allMounts: FSMounts = {};
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,33 @@
|
||||||
// Portions of this file are Copyright 2021 Google LLC, and licensed under GPL2+. See COPYING.
|
// Portions of this file are Copyright 2021 Google LLC, and licensed under GPL2+. See COPYING.
|
||||||
|
|
||||||
import { State } from "./app-state";
|
import { State } from "./app-state";
|
||||||
import { validateArray, validateString } from "./utils";
|
import { validateArray, validateBoolean, validateString, validateStringEnum } from "./utils";
|
||||||
|
|
||||||
export function writeStateInFragment(state: State) {
|
export function writeStateInFragment(state: State) {
|
||||||
window.location.hash = encodeURIComponent(JSON.stringify(state.params));
|
window.location.hash = encodeURIComponent(JSON.stringify({
|
||||||
|
params: state.params,
|
||||||
|
view: state.view
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
export function readStateFromFragment(): State | null {
|
export function readStateFromFragment(): State | null {
|
||||||
if (window.location.hash.startsWith('#') && window.location.hash.length > 1) {
|
if (window.location.hash.startsWith('#') && window.location.hash.length > 1) {
|
||||||
try {
|
try {
|
||||||
const params = JSON.parse(decodeURIComponent(window.location.hash.substring(1)));
|
const {params, view} = JSON.parse(decodeURIComponent(window.location.hash.substring(1)));
|
||||||
return {
|
return {
|
||||||
params: {
|
params: {
|
||||||
|
sourcePath: validateString(params?.sourcePath),
|
||||||
source: validateString(params?.source),
|
source: validateString(params?.source),
|
||||||
features: validateArray(params?.features, validateString),
|
features: validateArray(params?.features, validateString),
|
||||||
},
|
},
|
||||||
|
view: {
|
||||||
|
layout: {
|
||||||
|
mode: validateStringEnum(view?.layout?.mode, ['multi', 'single']),
|
||||||
|
focus: validateStringEnum(view?.layout?.focus, ['editor', 'viewer', 'customizer'], s => false),
|
||||||
|
editor: validateBoolean(view?.layout['editor']),
|
||||||
|
viewer: validateBoolean(view?.layout['viewer']),
|
||||||
|
customizer: validateBoolean(view?.layout['customizer']),
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {readStateFromFragment} from './fragment-state'
|
||||||
import { State } from './app-state';
|
import { State } from './app-state';
|
||||||
import defaultScad from './default-scad'
|
import defaultScad from './default-scad'
|
||||||
import './index.css';
|
import './index.css';
|
||||||
|
// import "react-widgets/styles.css";
|
||||||
|
|
||||||
import debug from 'debug';
|
import debug from 'debug';
|
||||||
const log = debug('app:log');
|
const log = debug('app:log');
|
||||||
|
|
@ -24,16 +25,54 @@ if (process.env.NODE_ENV !== 'production') {
|
||||||
(async () => {
|
(async () => {
|
||||||
|
|
||||||
const workingDir = '/home';
|
const workingDir = '/home';
|
||||||
const fs = await createEditorFS(workingDir);
|
const fs = await createEditorFS(workingDir)!;
|
||||||
await registerOpenSCADLanguage(fs, workingDir, zipArchives);
|
await registerOpenSCADLanguage(fs, workingDir, zipArchives);
|
||||||
|
|
||||||
const initialState: State = readStateFromFragment() ?? {
|
type Mode = State['view']['layout']['mode'];
|
||||||
|
const mode: Mode = window.matchMedia("(min-width: 768px)").matches
|
||||||
|
? 'multi' : 'single';
|
||||||
|
|
||||||
|
const defaultSourcePath = '/home/playground.scad';
|
||||||
|
const initialState: State = {
|
||||||
params: {
|
params: {
|
||||||
|
sourcePath: defaultSourcePath,
|
||||||
source: defaultScad,
|
source: defaultScad,
|
||||||
features: [],
|
features: [],
|
||||||
}
|
},
|
||||||
|
view: {
|
||||||
|
layout: {
|
||||||
|
mode: 'multi',
|
||||||
|
editor: true,
|
||||||
|
viewer: true,
|
||||||
|
customizer: false,
|
||||||
|
} as any
|
||||||
|
},
|
||||||
|
...(readStateFromFragment() ?? {})
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (initialState.view.layout.mode != mode) {
|
||||||
|
if (mode === 'multi' && initialState.view.layout.mode === 'single') {
|
||||||
|
initialState.view.layout = {
|
||||||
|
mode,
|
||||||
|
editor: true,
|
||||||
|
viewer: true,
|
||||||
|
customizer: initialState.view.layout.focus == 'customizer'
|
||||||
|
}
|
||||||
|
} else if (mode === 'single' && initialState.view.layout.mode === 'multi') {
|
||||||
|
initialState.view.layout = {
|
||||||
|
mode,
|
||||||
|
focus: initialState.view.layout.viewer ? 'viewer'
|
||||||
|
: initialState.view.layout.customizer ? 'customizer'
|
||||||
|
: 'editor'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFile(initialState.params.sourcePath, initialState.params.source);
|
||||||
|
if (initialState.params.sourcePath !== defaultSourcePath) {
|
||||||
|
fs.writeFile(defaultSourcePath, defaultScad);
|
||||||
|
}
|
||||||
|
|
||||||
const defaultFeatures = ['manifold', 'fast-csg', 'lazy-union'];
|
const defaultFeatures = ['manifold', 'fast-csg', 'lazy-union'];
|
||||||
defaultFeatures.forEach(f => {
|
defaultFeatures.forEach(f => {
|
||||||
if (initialState.params.features.indexOf(f) < 0)
|
if (initialState.params.features.indexOf(f) < 0)
|
||||||
|
|
@ -45,7 +84,7 @@ if (process.env.NODE_ENV !== 'production') {
|
||||||
);
|
);
|
||||||
root.render(
|
root.render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App initialState={initialState} />
|
<App initialState={initialState} fs={fs} />
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
);
|
);
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
lineNumbers: 'on',
|
lineNumbers: 'on',
|
||||||
automaticLayout: true,
|
// automaticLayout: true,
|
||||||
scrollBeyondLastLine: false,
|
scrollBeyondLastLine: false,
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
language: 'openscad',
|
language: 'openscad',
|
||||||
|
|
|
||||||
76
src/model.ts
76
src/model.ts
|
|
@ -1,12 +1,12 @@
|
||||||
// Portions of this file are Copyright 2021 Google LLC, and licensed under GPL2+. See COPYING.
|
// Portions of this file are Copyright 2021 Google LLC, and licensed under GPL2+. See COPYING.
|
||||||
|
|
||||||
import { checkSyntax, render, RenderArgs, RenderOutput } from "./actions";
|
import { checkSyntax, render, RenderArgs, RenderOutput } from "./actions";
|
||||||
import { State } from "./app-state";
|
import { MultiLayoutComponentId, SingleLayoutComponentId, State } from "./app-state";
|
||||||
import { bubbleUpDeepMutations } from "./deep-mutate";
|
import { bubbleUpDeepMutations } from "./deep-mutate";
|
||||||
import { writeStateInFragment } from "./fragment-state";
|
import { writeStateInFragment } from "./fragment-state";
|
||||||
|
|
||||||
export class Model {
|
export class Model {
|
||||||
constructor(public state: State, private setStateCallback?: (state: State) => void) {
|
constructor(private fs: FS, public state: State, private setStateCallback?: (state: State) => void) {
|
||||||
}
|
}
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
|
|
@ -34,6 +34,55 @@ export class Model {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
set logsVisible(value: boolean) {
|
||||||
|
this.mutate(s => s.view.logs = value);
|
||||||
|
}
|
||||||
|
|
||||||
|
changeLayout(mode: 'multi' | 'single') {
|
||||||
|
if (this.state.view.layout.mode === mode) return;
|
||||||
|
this.mutate(s => {
|
||||||
|
s.view.layout = s.view.layout.mode === 'multi'
|
||||||
|
? {
|
||||||
|
mode: 'single',
|
||||||
|
focus: s.view.layout.editor ? 'editor' : s.view.layout.viewer ? 'viewer' : 'customizer'
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
mode: 'multi',
|
||||||
|
editor: s.view.layout.focus === 'editor',
|
||||||
|
viewer: s.view.layout.focus === 'viewer',
|
||||||
|
customizer: s.view.layout.focus === 'customizer',
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
changeSingleVisibility(focus: SingleLayoutComponentId) {
|
||||||
|
this.mutate(s => {
|
||||||
|
if (s.view.layout.mode !== 'single') throw new Error('Wrong mode');
|
||||||
|
s.view.layout.focus = focus;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
changeMultiVisibility(target: MultiLayoutComponentId, visible: boolean) {
|
||||||
|
this.mutate(s => {
|
||||||
|
if (s.view.layout.mode !== 'multi') throw new Error('Wrong mode');
|
||||||
|
s.view.layout[target] = visible
|
||||||
|
if ((s.view.layout.customizer ? 1 : 0) + (s.view.layout.editor ? 1 : 0) + (s.view.layout.viewer ? 1 : 0) == 0) {
|
||||||
|
// Select at least one panel
|
||||||
|
// s.view.layout.editor = true;
|
||||||
|
s.view.layout[target] = !visible;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
openFile(path: string) {
|
||||||
|
// alert(`TODO: open ${path}`);
|
||||||
|
if (this.mutate(s => {
|
||||||
|
s.params.source = new TextDecoder("utf-8").decode(this.fs.readFileSync(path));
|
||||||
|
s.params.sourcePath = path;
|
||||||
|
})) {
|
||||||
|
this.processSource();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
set source(source: string) {
|
set source(source: string) {
|
||||||
if (this.mutate(s => { s.params.source = source; })) {
|
if (this.mutate(s => { s.params.source = source; })) {
|
||||||
this.processSource();
|
this.processSource();
|
||||||
|
|
@ -41,14 +90,20 @@ export class Model {
|
||||||
}
|
}
|
||||||
|
|
||||||
private processSource() {
|
private processSource() {
|
||||||
|
const params = this.state.params;
|
||||||
|
// this.fs.writeFile(params.sourcePath, params.source);
|
||||||
this.checkSyntax();
|
this.checkSyntax();
|
||||||
this.render({isPreview: true, now: false});
|
this.render({isPreview: true, now: false});
|
||||||
}
|
}
|
||||||
checkSyntax() {
|
checkSyntax() {
|
||||||
this.mutate(s => s.checkingSyntax = true);
|
this.mutate(s => s.checkingSyntax = true);
|
||||||
checkSyntax(this.state.params.source)({now: false, callback: checkerRun => this.mutate(s => {
|
checkSyntax(this.state.params.source, this.state.params.sourcePath)({now: false, callback: (checkerRun, err) => this.mutate(s => {
|
||||||
|
if (err != null) {
|
||||||
|
console.error('Error while checking syntax:', err)
|
||||||
|
} else {
|
||||||
s.lastCheckerRun = checkerRun;
|
s.lastCheckerRun = checkerRun;
|
||||||
s.checkingSyntax = false;
|
s.checkingSyntax = false;
|
||||||
|
}
|
||||||
})});
|
})});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -62,13 +117,16 @@ export class Model {
|
||||||
}
|
}
|
||||||
this.mutate(s => setRendering(s, true));
|
this.mutate(s => setRendering(s, true));
|
||||||
|
|
||||||
const source = this.state.params.source;
|
const {source, sourcePath, features} = this.state.params;
|
||||||
const features = this.state.params.features;
|
|
||||||
const renderArgs = {source, features, extraArgs: [], isPreview};
|
|
||||||
// console.log('renderArgs', renderArgs);
|
|
||||||
|
|
||||||
render(renderArgs)({now, callback: output => {
|
render({source, sourcePath, features, extraArgs: [], isPreview})({now, callback: (output, err) => {
|
||||||
this.mutate(s => {
|
this.mutate(s => {
|
||||||
|
setRendering(s, false);
|
||||||
|
if (err != null) {
|
||||||
|
console.error('Error while doing ' + (isPreview ? 'preview' : 'rendering') + ':', err)
|
||||||
|
s.error = `${err}`;
|
||||||
|
} else if (output) {
|
||||||
|
s.error = undefined;
|
||||||
s.lastCheckerRun = {
|
s.lastCheckerRun = {
|
||||||
logText: output.logText,
|
logText: output.logText,
|
||||||
markers: output.markers,
|
markers: output.markers,
|
||||||
|
|
@ -85,7 +143,7 @@ export class Model {
|
||||||
formattedElapsedMillis: formatMillis(output.elapsedMillis),
|
formattedElapsedMillis: formatMillis(output.elapsedMillis),
|
||||||
formattedStlFileSize: formatBytes(output.stlFile.size),
|
formattedStlFileSize: formatBytes(output.stlFile.size),
|
||||||
};
|
};
|
||||||
setRendering(s, false);
|
}
|
||||||
});
|
});
|
||||||
}})
|
}})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ export function createWasmMemory({maximumMegabytes, maximumBytes}: {maximumMegab
|
||||||
// Output is {outputs: [name, content][], mergedOutputs: [{(stderr|stdout|error)?: string}], exitCode: number}
|
// Output is {outputs: [name, content][], mergedOutputs: [{(stderr|stdout|error)?: string}], exitCode: number}
|
||||||
export type OpenSCADInvocation = {
|
export type OpenSCADInvocation = {
|
||||||
wasmMemory?: WebAssembly.Memory,
|
wasmMemory?: WebAssembly.Memory,
|
||||||
|
workingDir: string,
|
||||||
inputs?: [string, string][],
|
inputs?: [string, string][],
|
||||||
args: string[],
|
args: string[],
|
||||||
outputPaths?: string[],
|
outputPaths?: string[],
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,13 @@
|
||||||
|
|
||||||
import OpenSCAD from "./wasm/openscad.js";
|
import OpenSCAD from "./wasm/openscad.js";
|
||||||
|
|
||||||
import { getBrowserFSLibrariesMounts, symlinkLibraries } from "./filesystem";
|
import { getBrowserFSLibrariesMounts, getParentDir, symlinkLibraries } from "./filesystem";
|
||||||
import { OpenSCADInvocation, OpenSCADInvocationResults } from "./openscad-runner";
|
import { OpenSCADInvocation, OpenSCADInvocationResults } from "./openscad-runner";
|
||||||
import { zipArchives } from "./zip-archives";
|
import { zipArchives } from "./zip-archives";
|
||||||
declare var BrowserFS: BrowserFSInterface
|
declare var BrowserFS: BrowserFSInterface
|
||||||
|
|
||||||
// importScripts("browserfs.min.js");
|
importScripts("browserfs.min.js");
|
||||||
importScripts("https://cdnjs.cloudflare.com/ajax/libs/BrowserFS/2.0.0/browserfs.min.js");
|
// importScripts("https://cdnjs.cloudflare.com/ajax/libs/BrowserFS/2.0.0/browserfs.min.js");
|
||||||
|
|
||||||
const allArchiveNames = Object.keys(zipArchives)
|
const allArchiveNames = Object.keys(zipArchives)
|
||||||
const allZipMountsPromise = getBrowserFSLibrariesMounts(allArchiveNames);
|
const allZipMountsPromise = getBrowserFSLibrariesMounts(allArchiveNames);
|
||||||
|
|
@ -17,7 +17,7 @@ export type MergedOutputs = {stdout?: string, stderr?: string, error?: string}[]
|
||||||
|
|
||||||
addEventListener('message', async (e) => {
|
addEventListener('message', async (e) => {
|
||||||
|
|
||||||
const { inputs, args, outputPaths, wasmMemory } = e.data as OpenSCADInvocation;
|
const { inputs, args, outputPaths, wasmMemory, workingDir } = e.data as OpenSCADInvocation;
|
||||||
|
|
||||||
const mergedOutputs: MergedOutputs = [];
|
const mergedOutputs: MergedOutputs = [];
|
||||||
try {
|
try {
|
||||||
|
|
@ -33,8 +33,14 @@ addEventListener('message', async (e) => {
|
||||||
// console.log('stderr: ' + text);
|
// console.log('stderr: ' + text);
|
||||||
mergedOutputs.push({ stderr: text })
|
mergedOutputs.push({ stderr: text })
|
||||||
},
|
},
|
||||||
|
ENV: {
|
||||||
|
OPENSCADPATH: '/libraries:/home'
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// console.error('INSTANCE')
|
||||||
|
// console.error(Object.keys(instance))
|
||||||
|
|
||||||
// await browserFSInit;
|
// await browserFSInit;
|
||||||
await new Promise(async (resolve, reject) => {
|
await new Promise(async (resolve, reject) => {
|
||||||
BrowserFS.install(self);
|
BrowserFS.install(self);
|
||||||
|
|
@ -50,7 +56,8 @@ addEventListener('message', async (e) => {
|
||||||
const archiveNames = allArchiveNames;
|
const archiveNames = allArchiveNames;
|
||||||
|
|
||||||
// instance.FS.mkdir('tmp');
|
// instance.FS.mkdir('tmp');
|
||||||
instance.FS.mkdir('/tmp/run');
|
// instance.FS.mkdir('/home');
|
||||||
|
// instance.FS.mkdir('/tmp/run');
|
||||||
instance.FS.mkdir('/libraries');
|
instance.FS.mkdir('/libraries');
|
||||||
|
|
||||||
// await browserFSInit;
|
// await browserFSInit;
|
||||||
|
|
@ -64,9 +71,13 @@ addEventListener('message', async (e) => {
|
||||||
}, instance.ERRNO_CODES ?? {});
|
}, instance.ERRNO_CODES ?? {});
|
||||||
instance.FS.mount(BFS, {root: '/'}, '/libraries');
|
instance.FS.mount(BFS, {root: '/'}, '/libraries');
|
||||||
|
|
||||||
await symlinkLibraries(archiveNames, instance.FS, '/libraries', '/tmp/run');
|
// await symlinkLibraries(archiveNames, instance.FS, '/libraries', '/tmp/run');
|
||||||
|
await symlinkLibraries(archiveNames, instance.FS, '/libraries', '/home');
|
||||||
|
|
||||||
instance.FS.chdir('/tmp/run');
|
// const
|
||||||
|
// instance.FS.chdir('/tmp/run');
|
||||||
|
instance.FS.chdir('/home');
|
||||||
|
// instance.FS.chdir(workingDir);
|
||||||
|
|
||||||
// console.log('.', instance.FS.readdir('.'));
|
// console.log('.', instance.FS.readdir('.'));
|
||||||
// console.log('fonts', instance.FS.readdir('fonts'));
|
// console.log('fonts', instance.FS.readdir('fonts'));
|
||||||
|
|
@ -74,11 +85,16 @@ addEventListener('message', async (e) => {
|
||||||
|
|
||||||
if (inputs) {
|
if (inputs) {
|
||||||
for (const [path, content] of inputs) {
|
for (const [path, content] of inputs) {
|
||||||
|
try {
|
||||||
|
// const parent = getParentDir(path);
|
||||||
instance.FS.writeFile(path, content);
|
instance.FS.writeFile(path, content);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Error while trying to write ${path}`, e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.debug('Calling main ', args)
|
console.error('Calling main ', args)
|
||||||
const start = performance.now();
|
const start = performance.now();
|
||||||
const exitCode = instance.callMain(args);
|
const exitCode = instance.callMain(args);
|
||||||
const end = performance.now();
|
const end = performance.now();
|
||||||
|
|
@ -94,8 +110,7 @@ addEventListener('message', async (e) => {
|
||||||
|
|
||||||
postMessage(result);
|
postMessage(result);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
console.trace(e);//, e instanceof Error ? e.stack : '');
|
||||||
console.error(e);
|
|
||||||
const error = `${e}`;
|
const error = `${e}`;
|
||||||
mergedOutputs.push({ error });
|
mergedOutputs.push({ error });
|
||||||
postMessage({
|
postMessage({
|
||||||
|
|
|
||||||
10
src/utils.ts
10
src/utils.ts
|
|
@ -29,7 +29,7 @@ export function turnIntoDelayableExecution<T extends any[], R>(
|
||||||
let pendingId: number | null;
|
let pendingId: number | null;
|
||||||
let runningJobKillSignal: (() => void) | null;
|
let runningJobKillSignal: (() => void) | null;
|
||||||
|
|
||||||
return (...args: T) => async ({now, callback}: {now: boolean, callback: (result: R) => void}) => {
|
return (...args: T) => async ({now, callback}: {now: boolean, callback: (result?: R, error?: any) => void}) => {
|
||||||
const doExecute = async () => {
|
const doExecute = async () => {
|
||||||
if (runningJobKillSignal) {
|
if (runningJobKillSignal) {
|
||||||
runningJobKillSignal();
|
runningJobKillSignal();
|
||||||
|
|
@ -39,6 +39,8 @@ export function turnIntoDelayableExecution<T extends any[], R>(
|
||||||
runningJobKillSignal = abortablePromise.kill;
|
runningJobKillSignal = abortablePromise.kill;
|
||||||
try {
|
try {
|
||||||
callback(await abortablePromise);
|
callback(await abortablePromise);
|
||||||
|
} catch (e) {
|
||||||
|
callback(undefined, e);
|
||||||
} finally {
|
} finally {
|
||||||
runningJobKillSignal = null;
|
runningJobKillSignal = null;
|
||||||
}
|
}
|
||||||
|
|
@ -55,6 +57,12 @@ export function turnIntoDelayableExecution<T extends any[], R>(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function validateStringEnum<T extends string>(
|
||||||
|
s: T, values: T[],
|
||||||
|
orElse: (s: string) => T = s => { throw new Error(`Unexpected value: ${s} (valid values: ${values.join(', ')})`); }): T {
|
||||||
|
return values.indexOf(s) < 0 ? orElse(s) : s;
|
||||||
|
}
|
||||||
|
export const validateBoolean = (s: boolean, orElse: () => boolean = () => false) => typeof s === 'boolean' ? s : orElse();
|
||||||
export const validateString = (s: string, orElse: () => string = () => '') => s != null && typeof s === 'string' ? s : orElse();
|
export const validateString = (s: string, orElse: () => string = () => '') => s != null && typeof s === 'string' ? s : orElse();
|
||||||
export const validateArray = <T>(a: Array<T>, validateElement: (e: T) => T, orElse: () => T[] = () => []) => {
|
export const validateArray = <T>(a: Array<T>, validateElement: (e: T) => T, orElse: () => T[] = () => []) => {
|
||||||
if (!(a instanceof Array)) return orElse();
|
if (!(a instanceof Array)) return orElse();
|
||||||
|
|
|
||||||
|
|
@ -21,10 +21,10 @@ module.exports = {
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
// {
|
||||||
test: /\.(png|gif|woff|woff2|eot|ttf|svg)$/,
|
// test: /\.(png|gif|woff|woff2|eot|ttf|svg)$/,
|
||||||
loader: "url-loader?limit=100000"
|
// loader: "url-loader?limit=100000"
|
||||||
},
|
// },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue