NodeEditor.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784
  1. import * as THREE from 'three';
  2. import * as Nodes from 'three/nodes';
  3. import { Canvas, CircleMenu, ButtonInput, StringInput, ContextMenu, Tips, Search, Loader, Node, TreeViewNode, TreeViewInput, Element } from 'flow';
  4. import { FileEditor } from './editors/FileEditor.js';
  5. import { exportJSON } from './NodeEditorUtils.js';
  6. import { init, ClassLib, getNodeEditorClass, getNodeList } from './NodeEditorLib.js';
  7. init();
  8. Element.icons.unlink = 'ti ti-unlink';
  9. export class NodeEditor extends THREE.EventDispatcher {
  10. constructor( scene = null, renderer = null, composer = null ) {
  11. super();
  12. const domElement = document.createElement( 'flow' );
  13. const canvas = new Canvas();
  14. domElement.append( canvas.dom );
  15. this.scene = scene;
  16. this.renderer = renderer;
  17. const { global } = Nodes;
  18. global.set( 'THREE', THREE );
  19. global.set( 'TSL', Nodes );
  20. global.set( 'scene', scene );
  21. global.set( 'renderer', renderer );
  22. global.set( 'composer', composer );
  23. this.nodeClasses = [];
  24. this.canvas = canvas;
  25. this.domElement = domElement;
  26. this._preview = false;
  27. this.search = null;
  28. this.menu = null;
  29. this.previewMenu = null;
  30. this.nodesContext = null;
  31. this.examplesContext = null;
  32. this._initUpload();
  33. this._initTips();
  34. this._initMenu();
  35. this._initSearch();
  36. this._initNodesContext();
  37. this._initExamplesContext();
  38. this._initShortcuts();
  39. this._initParams();
  40. }
  41. setSize( width, height ) {
  42. this.canvas.setSize( width, height );
  43. return this;
  44. }
  45. centralizeNode( node ) {
  46. const canvas = this.canvas;
  47. const nodeRect = node.dom.getBoundingClientRect();
  48. node.setPosition(
  49. ( ( canvas.width / 2 ) - canvas.scrollLeft ) - nodeRect.width,
  50. ( ( canvas.height / 2 ) - canvas.scrollTop ) - nodeRect.height
  51. );
  52. return this;
  53. }
  54. add( node ) {
  55. const onRemove = () => {
  56. node.removeEventListener( 'remove', onRemove );
  57. node.setEditor( null );
  58. };
  59. node.setEditor( this );
  60. node.addEventListener( 'remove', onRemove );
  61. this.canvas.add( node );
  62. this.dispatchEvent( { type: 'add', node } );
  63. return this;
  64. }
  65. get nodes() {
  66. return this.canvas.nodes;
  67. }
  68. set preview( value ) {
  69. if ( this._preview === value ) return;
  70. if ( value ) {
  71. this.menu.dom.remove();
  72. this.canvas.dom.remove();
  73. this.search.dom.remove();
  74. this.domElement.append( this.previewMenu.dom );
  75. } else {
  76. this.canvas.focusSelected = false;
  77. this.domElement.append( this.menu.dom );
  78. this.domElement.append( this.canvas.dom );
  79. this.domElement.append( this.search.dom );
  80. this.previewMenu.dom.remove();
  81. }
  82. this._preview = value;
  83. }
  84. get preview() {
  85. return this._preview;
  86. }
  87. newProject() {
  88. const canvas = this.canvas;
  89. canvas.clear();
  90. canvas.scrollLeft = 0;
  91. canvas.scrollTop = 0;
  92. canvas.zoom = 1;
  93. this.dispatchEvent( { type: 'new' } );
  94. }
  95. async loadURL( url ) {
  96. const loader = new Loader( Loader.OBJECTS );
  97. const json = await loader.load( url, ClassLib );
  98. this.loadJSON( json );
  99. }
  100. loadJSON( json ) {
  101. const canvas = this.canvas;
  102. canvas.clear();
  103. canvas.deserialize( json );
  104. for ( const node of canvas.nodes ) {
  105. this.add( node );
  106. }
  107. this.dispatchEvent( { type: 'load' } );
  108. }
  109. _initUpload() {
  110. const canvas = this.canvas;
  111. canvas.onDrop( () => {
  112. for ( const item of canvas.droppedItems ) {
  113. const { relativeClientX, relativeClientY } = canvas;
  114. const file = item.getAsFile();
  115. const reader = new FileReader();
  116. reader.onload = () => {
  117. const fileEditor = new FileEditor( reader.result, file.name );
  118. fileEditor.setPosition(
  119. relativeClientX - ( fileEditor.getWidth() / 2 ),
  120. relativeClientY - 20
  121. );
  122. this.add( fileEditor );
  123. };
  124. reader.readAsArrayBuffer( file );
  125. }
  126. } );
  127. }
  128. _initTips() {
  129. this.tips = new Tips();
  130. this.domElement.append( this.tips.dom );
  131. }
  132. _initMenu() {
  133. const menu = new CircleMenu();
  134. const previewMenu = new CircleMenu();
  135. menu.setAlign( 'top left' );
  136. previewMenu.setAlign( 'top left' );
  137. const previewButton = new ButtonInput().setIcon( 'ti ti-brand-threejs' ).setToolTip( 'Preview' );
  138. const menuButton = new ButtonInput().setIcon( 'ti ti-apps' ).setToolTip( 'Add' );
  139. const examplesButton = new ButtonInput().setIcon( 'ti ti-file-symlink' ).setToolTip( 'Examples' );
  140. const newButton = new ButtonInput().setIcon( 'ti ti-file' ).setToolTip( 'New' );
  141. const openButton = new ButtonInput().setIcon( 'ti ti-upload' ).setToolTip( 'Open' );
  142. const saveButton = new ButtonInput().setIcon( 'ti ti-download' ).setToolTip( 'Save' );
  143. const editorButton = new ButtonInput().setIcon( 'ti ti-subtask' ).setToolTip( 'Editor' );
  144. previewButton.onClick( () => this.preview = true );
  145. editorButton.onClick( () => this.preview = false );
  146. menuButton.onClick( () => this.nodesContext.open() );
  147. examplesButton.onClick( () => this.examplesContext.open() );
  148. newButton.onClick( () => {
  149. if ( confirm( 'Are you sure?' ) === true ) {
  150. this.newProject();
  151. }
  152. } );
  153. openButton.onClick( () => {
  154. const input = document.createElement( 'input' );
  155. input.type = 'file';
  156. input.onchange = e => {
  157. const file = e.target.files[ 0 ];
  158. const reader = new FileReader();
  159. reader.readAsText( file, 'UTF-8' );
  160. reader.onload = readerEvent => {
  161. const loader = new Loader( Loader.OBJECTS );
  162. const json = loader.parse( JSON.parse( readerEvent.target.result ), ClassLib );
  163. this.loadJSON( json );
  164. };
  165. };
  166. input.click();
  167. } );
  168. saveButton.onClick( () => {
  169. exportJSON( this.canvas.toJSON(), 'node_editor' );
  170. } );
  171. menu.add( previewButton )
  172. .add( newButton )
  173. .add( examplesButton )
  174. .add( openButton )
  175. .add( saveButton )
  176. .add( menuButton );
  177. previewMenu.add( editorButton );
  178. this.domElement.append( menu.dom );
  179. this.menu = menu;
  180. this.previewMenu = previewMenu;
  181. }
  182. _initExamplesContext() {
  183. const context = new ContextMenu();
  184. //**************//
  185. // MAIN
  186. //**************//
  187. const onClickExample = async ( button ) => {
  188. this.examplesContext.hide();
  189. const filename = button.getExtra();
  190. this.loadURL( `./examples/${filename}.json` );
  191. };
  192. const addExamples = ( category, names ) => {
  193. const subContext = new ContextMenu();
  194. for ( const name of names ) {
  195. const filename = name.replaceAll( ' ', '-' ).toLowerCase();
  196. subContext.add( new ButtonInput( name )
  197. .setIcon( 'ti ti-file-symlink' )
  198. .onClick( onClickExample )
  199. .setExtra( category.toLowerCase() + '/' + filename )
  200. );
  201. }
  202. context.add( new ButtonInput( category ), subContext );
  203. return subContext;
  204. };
  205. //**************//
  206. // EXAMPLES
  207. //**************//
  208. addExamples( 'Universal', [
  209. 'Teapot',
  210. 'Matcap',
  211. 'Fresnel'
  212. ] );
  213. if ( this.renderer.isWebGLRenderer ) {
  214. addExamples( 'WebGL', [
  215. 'Car'
  216. ] );
  217. context.add( new ButtonInput( 'WebGPU Version' ).onClick( () => {
  218. if ( confirm( 'Are you sure?' ) === true ) {
  219. window.location.search = '?backend=webgpu';
  220. }
  221. } ) );
  222. } else if ( this.renderer.isWebGPURenderer ) {
  223. addExamples( 'WebGPU', [
  224. 'Particle'
  225. ] );
  226. context.add( new ButtonInput( 'WebGL Version' ).onClick( () => {
  227. if ( confirm( 'Are you sure?' ) === true ) {
  228. window.location.search = '';
  229. }
  230. } ) );
  231. }
  232. this.examplesContext = context;
  233. }
  234. _initShortcuts() {
  235. document.addEventListener( 'keydown', ( e ) => {
  236. if ( e.target === document.body ) {
  237. const key = e.key;
  238. if ( key === 'Tab' ) {
  239. this.search.inputDOM.focus();
  240. e.preventDefault();
  241. e.stopImmediatePropagation();
  242. } else if ( key === ' ' ) {
  243. this.preview = ! this.preview;
  244. } else if ( key === 'Delete' ) {
  245. if ( this.canvas.selected ) this.canvas.selected.dispose();
  246. } else if ( key === 'Escape' ) {
  247. this.canvas.select( null );
  248. }
  249. }
  250. } );
  251. }
  252. _initParams() {
  253. const urlParams = new URLSearchParams( window.location.search );
  254. const example = urlParams.get( 'example' ) || 'universal/teapot';
  255. this.loadURL( `./examples/${example}.json` );
  256. }
  257. addClass( nodeData ) {
  258. this.removeClass( nodeData );
  259. this.nodeClasses.push( nodeData );
  260. ClassLib[ nodeData.name ] = nodeData.nodeClass;
  261. return this;
  262. }
  263. removeClass( nodeData ) {
  264. const index = this.nodeClasses.indexOf( nodeData );
  265. if ( index !== - 1 ) {
  266. this.nodeClasses.splice( index, 1 );
  267. delete ClassLib[ nodeData.name ];
  268. }
  269. return this;
  270. }
  271. _initSearch() {
  272. const traverseNodeEditors = ( item ) => {
  273. if ( item.children ) {
  274. for ( const subItem of item.children ) {
  275. traverseNodeEditors( subItem );
  276. }
  277. } else {
  278. const button = new ButtonInput( item.name );
  279. button.setIcon( `ti ti-${item.icon}` );
  280. button.addEventListener( 'complete', async () => {
  281. const nodeClass = await getNodeEditorClass( item );
  282. const node = new nodeClass();
  283. this.add( node );
  284. this.centralizeNode( node );
  285. this.canvas.select( node );
  286. } );
  287. search.add( button );
  288. if ( item.tags !== undefined ) {
  289. search.setTag( button, item.tags );
  290. }
  291. }
  292. };
  293. const search = new Search();
  294. search.forceAutoComplete = true;
  295. search.onFilter( async () => {
  296. search.clear();
  297. const nodeList = await getNodeList();
  298. for ( const item of nodeList.nodes ) {
  299. traverseNodeEditors( item );
  300. }
  301. for ( const item of this.nodeClasses ) {
  302. traverseNodeEditors( item );
  303. }
  304. } );
  305. search.onSubmit( () => {
  306. if ( search.currentFiltered !== null ) {
  307. search.currentFiltered.button.dispatchEvent( new Event( 'complete' ) );
  308. }
  309. } );
  310. this.search = search;
  311. this.domElement.append( search.dom );
  312. }
  313. async _initNodesContext() {
  314. const context = new ContextMenu( this.canvas.canvas ).setWidth( 300 );
  315. let isContext = false;
  316. const contextPosition = {};
  317. const add = ( node ) => {
  318. context.hide();
  319. this.add( node );
  320. if ( isContext ) {
  321. node.setPosition(
  322. Math.round( contextPosition.x ),
  323. Math.round( contextPosition.y )
  324. );
  325. } else {
  326. this.centralizeNode( node );
  327. }
  328. this.canvas.select( node );
  329. isContext = false;
  330. };
  331. context.onContext( () => {
  332. isContext = true;
  333. const { relativeClientX, relativeClientY } = this.canvas;
  334. contextPosition.x = Math.round( relativeClientX );
  335. contextPosition.y = Math.round( relativeClientY );
  336. } );
  337. context.addEventListener( 'show', () => {
  338. reset();
  339. focus();
  340. } );
  341. //**************//
  342. // INPUTS
  343. //**************//
  344. const nodeButtons = [];
  345. let nodeButtonsVisible = [];
  346. let nodeButtonsIndex = - 1;
  347. const focus = () => requestAnimationFrame( () => search.inputDOM.focus() );
  348. const reset = () => {
  349. search.setValue( '', false );
  350. for ( const button of nodeButtons ) {
  351. button.setOpened( false ).setVisible( true ).setSelected( false );
  352. }
  353. };
  354. const node = new Node();
  355. context.add( node );
  356. const search = new StringInput().setPlaceHolder( 'Search...' ).setIcon( 'ti ti-list-search' );
  357. search.inputDOM.addEventListener( 'keydown', e => {
  358. const key = e.key;
  359. if ( key === 'ArrowDown' ) {
  360. const previous = nodeButtonsVisible[ nodeButtonsIndex ];
  361. if ( previous ) previous.setSelected( false );
  362. const current = nodeButtonsVisible[ nodeButtonsIndex = ( nodeButtonsIndex + 1 ) % nodeButtonsVisible.length ];
  363. if ( current ) current.setSelected( true );
  364. e.preventDefault();
  365. e.stopImmediatePropagation();
  366. } else if ( key === 'ArrowUp' ) {
  367. const previous = nodeButtonsVisible[ nodeButtonsIndex ];
  368. if ( previous ) previous.setSelected( false );
  369. const current = nodeButtonsVisible[ nodeButtonsIndex > 0 ? -- nodeButtonsIndex : ( nodeButtonsIndex = nodeButtonsVisible.length - 1 ) ];
  370. if ( current ) current.setSelected( true );
  371. e.preventDefault();
  372. e.stopImmediatePropagation();
  373. } else if ( key === 'Enter' ) {
  374. if ( nodeButtonsVisible[ nodeButtonsIndex ] !== undefined ) {
  375. nodeButtonsVisible[ nodeButtonsIndex ].dom.click();
  376. } else {
  377. context.hide();
  378. }
  379. e.preventDefault();
  380. e.stopImmediatePropagation();
  381. } else if ( key === 'Escape' ) {
  382. context.hide();
  383. }
  384. } );
  385. search.onChange( () => {
  386. const value = search.getValue().toLowerCase();
  387. if ( value.length === 0 ) return reset();
  388. nodeButtonsVisible = [];
  389. nodeButtonsIndex = 0;
  390. for ( const button of nodeButtons ) {
  391. const buttonLabel = button.getLabel().toLowerCase();
  392. button.setVisible( false ).setSelected( false );
  393. const visible = buttonLabel.indexOf( value ) !== - 1;
  394. if ( visible && button.children.length === 0 ) {
  395. nodeButtonsVisible.push( button );
  396. }
  397. }
  398. for ( const button of nodeButtonsVisible ) {
  399. let parent = button;
  400. while ( parent !== null ) {
  401. parent.setOpened( true ).setVisible( true );
  402. parent = parent.parent;
  403. }
  404. }
  405. if ( nodeButtonsVisible[ nodeButtonsIndex ] !== undefined ) {
  406. nodeButtonsVisible[ nodeButtonsIndex ].setSelected( true );
  407. }
  408. } );
  409. const treeView = new TreeViewInput();
  410. node.add( new Element().setHeight( 30 ).add( search ) );
  411. node.add( new Element().setHeight( 200 ).add( treeView ) );
  412. const addNodeEditorElement = ( nodeData ) => {
  413. const button = new TreeViewNode( nodeData.name );
  414. button.setIcon( `ti ti-${nodeData.icon}` );
  415. if ( nodeData.children === undefined ) {
  416. button.isNodeClass = true;
  417. button.onClick( async () => {
  418. const nodeClass = await getNodeEditorClass( nodeData );
  419. add( new nodeClass() );
  420. } );
  421. }
  422. if ( nodeData.tip ) {
  423. //button.setToolTip( item.tip );
  424. }
  425. nodeButtons.push( button );
  426. if ( nodeData.children ) {
  427. for ( const subItem of nodeData.children ) {
  428. const subButton = addNodeEditorElement( subItem );
  429. button.add( subButton );
  430. }
  431. }
  432. return button;
  433. };
  434. //
  435. const nodeList = await getNodeList();
  436. for ( const node of nodeList.nodes ) {
  437. const button = addNodeEditorElement( node );
  438. treeView.add( button );
  439. }
  440. this.nodesContext = context;
  441. }
  442. }