Gooood progress

This commit is contained in:
ochafik 2023-03-26 07:48:04 +01:00
commit 6528baa7b6
17 changed files with 752 additions and 171 deletions

8
.gitignore vendored
View file

@ -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

View file

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

View file

@ -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>

View file

@ -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}
&nbsp;
{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
View file

@ -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 {}

View file

@ -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];

View file

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

View file

@ -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) {

View file

@ -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 = {};

View file

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

View file

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

View file

@ -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',

View file

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

View file

@ -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[],

View file

@ -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({

View file

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

View file

@ -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: {