NodeEditor.js 14 KB

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