NodeEditor.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777
  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. }
  245. }
  246. } );
  247. }
  248. _initParams() {
  249. const urlParams = new URLSearchParams( window.location.search );
  250. const example = urlParams.get( 'example' ) || 'universal/teapot';
  251. this.loadURL( `./examples/${example}.json` );
  252. }
  253. addClass( nodeData ) {
  254. this.removeClass( nodeData );
  255. this.nodeClasses.push( nodeData );
  256. ClassLib[ nodeData.name ] = nodeData.nodeClass;
  257. return this;
  258. }
  259. removeClass( nodeData ) {
  260. const index = this.nodeClasses.indexOf( nodeData );
  261. if ( index !== - 1 ) {
  262. this.nodeClasses.splice( index, 1 );
  263. delete ClassLib[ nodeData.name ];
  264. }
  265. return this;
  266. }
  267. _initSearch() {
  268. const traverseNodeEditors = ( item ) => {
  269. if ( item.children ) {
  270. for ( const subItem of item.children ) {
  271. traverseNodeEditors( subItem );
  272. }
  273. } else {
  274. const button = new ButtonInput( item.name );
  275. button.setIcon( `ti ti-${item.icon}` );
  276. button.addEventListener( 'complete', async () => {
  277. const nodeClass = await getNodeEditorClass( item );
  278. const node = new nodeClass();
  279. this.add( node );
  280. this.centralizeNode( node );
  281. this.canvas.select( node );
  282. } );
  283. search.add( button );
  284. if ( item.tags !== undefined ) {
  285. search.setTag( button, item.tags );
  286. }
  287. }
  288. };
  289. const search = new Search();
  290. search.forceAutoComplete = true;
  291. search.onFilter( async () => {
  292. search.clear();
  293. const nodeList = await getNodeList();
  294. for ( const item of nodeList.nodes ) {
  295. traverseNodeEditors( item );
  296. }
  297. for ( const item of this.nodeClasses ) {
  298. traverseNodeEditors( item );
  299. }
  300. } );
  301. search.onSubmit( () => {
  302. if ( search.currentFiltered !== null ) {
  303. search.currentFiltered.button.dispatchEvent( new Event( 'complete' ) );
  304. }
  305. } );
  306. this.search = search;
  307. this.domElement.append( search.dom );
  308. }
  309. async _initNodesContext() {
  310. const context = new ContextMenu( this.canvas.canvas ).setWidth( 300 );
  311. let isContext = false;
  312. const contextPosition = {};
  313. const add = ( node ) => {
  314. context.hide();
  315. this.add( node );
  316. if ( isContext ) {
  317. node.setPosition(
  318. Math.round( contextPosition.x ),
  319. Math.round( contextPosition.y )
  320. );
  321. } else {
  322. this.centralizeNode( node );
  323. }
  324. this.canvas.select( node );
  325. isContext = false;
  326. };
  327. context.onContext( () => {
  328. isContext = true;
  329. const { relativeClientX, relativeClientY } = this.canvas;
  330. contextPosition.x = Math.round( relativeClientX );
  331. contextPosition.y = Math.round( relativeClientY );
  332. } );
  333. context.addEventListener( 'show', () => {
  334. reset();
  335. focus();
  336. } );
  337. //**************//
  338. // INPUTS
  339. //**************//
  340. const nodeButtons = [];
  341. let nodeButtonsVisible = [];
  342. let nodeButtonsIndex = - 1;
  343. const focus = () => requestAnimationFrame( () => search.inputDOM.focus() );
  344. const reset = () => {
  345. search.setValue( '', false );
  346. for ( const button of nodeButtons ) {
  347. button.setOpened( false ).setVisible( true ).setSelected( false );
  348. }
  349. };
  350. const node = new Node();
  351. context.add( node );
  352. const search = new StringInput().setPlaceHolder( 'Search...' ).setIcon( 'ti ti-list-search' );
  353. search.inputDOM.addEventListener( 'keydown', e => {
  354. const key = e.key;
  355. if ( key === 'ArrowDown' ) {
  356. const previous = nodeButtonsVisible[ nodeButtonsIndex ];
  357. if ( previous ) previous.setSelected( false );
  358. const current = nodeButtonsVisible[ nodeButtonsIndex = ( nodeButtonsIndex + 1 ) % nodeButtonsVisible.length ];
  359. if ( current ) current.setSelected( true );
  360. e.preventDefault();
  361. e.stopImmediatePropagation();
  362. } else if ( key === 'ArrowUp' ) {
  363. const previous = nodeButtonsVisible[ nodeButtonsIndex ];
  364. if ( previous ) previous.setSelected( false );
  365. const current = nodeButtonsVisible[ nodeButtonsIndex > 0 ? -- nodeButtonsIndex : ( nodeButtonsIndex = nodeButtonsVisible.length - 1 ) ];
  366. if ( current ) current.setSelected( true );
  367. e.preventDefault();
  368. e.stopImmediatePropagation();
  369. } else if ( key === 'Enter' ) {
  370. if ( nodeButtonsVisible[ nodeButtonsIndex ] !== undefined ) {
  371. nodeButtonsVisible[ nodeButtonsIndex ].dom.click();
  372. } else {
  373. context.hide();
  374. }
  375. e.preventDefault();
  376. e.stopImmediatePropagation();
  377. } else if ( key === 'Escape' ) {
  378. context.hide();
  379. }
  380. } );
  381. search.onChange( () => {
  382. const value = search.getValue().toLowerCase();
  383. if ( value.length === 0 ) return reset();
  384. nodeButtonsVisible = [];
  385. nodeButtonsIndex = 0;
  386. for ( const button of nodeButtons ) {
  387. const buttonLabel = button.getLabel().toLowerCase();
  388. button.setVisible( false ).setSelected( false );
  389. const visible = buttonLabel.indexOf( value ) !== - 1;
  390. if ( visible && button.parent !== null ) {
  391. nodeButtonsVisible.push( button );
  392. }
  393. }
  394. for ( const button of nodeButtonsVisible ) {
  395. let parent = button;
  396. while ( parent !== null ) {
  397. parent.setOpened( true ).setVisible( true );
  398. parent = parent.parent;
  399. }
  400. }
  401. if ( nodeButtonsVisible[ nodeButtonsIndex ] !== undefined ) {
  402. nodeButtonsVisible[ nodeButtonsIndex ].setSelected( true );
  403. }
  404. } );
  405. const treeView = new TreeViewInput();
  406. node.add( new Element().setHeight( 30 ).add( search ) );
  407. node.add( new Element().setHeight( 200 ).add( treeView ) );
  408. const addNodeEditorElement = ( nodeData ) => {
  409. const button = new TreeViewNode( nodeData.name );
  410. button.setIcon( `ti ti-${nodeData.icon}` );
  411. if ( nodeData.children === undefined ) {
  412. button.isNodeClass = true;
  413. button.onClick( async () => {
  414. const nodeClass = await getNodeEditorClass( nodeData );
  415. add( new nodeClass() );
  416. } );
  417. }
  418. if ( nodeData.tip ) {
  419. //button.setToolTip( item.tip );
  420. }
  421. nodeButtons.push( button );
  422. if ( nodeData.children ) {
  423. for ( const subItem of nodeData.children ) {
  424. const subButton = addNodeEditorElement( subItem );
  425. button.add( subButton );
  426. }
  427. }
  428. return button;
  429. };
  430. //
  431. const nodeList = await getNodeList();
  432. for ( const node of nodeList.nodes ) {
  433. const button = addNodeEditorElement( node );
  434. treeView.add( button );
  435. }
  436. this.nodesContext = context;
  437. }
  438. }