panel.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712
  1. /* global chrome */
  2. const CONNECTION_NAME = 'three-devtools';
  3. const STATE_POLLING_INTERVAL = 1000;
  4. // --- Utility Functions ---
  5. function getObjectIcon( obj ) {
  6. if ( obj.isScene ) return '🌍';
  7. if ( obj.isCamera ) return '📷';
  8. if ( obj.isLight ) return '💡';
  9. if ( obj.isInstancedMesh ) return '🔸';
  10. if ( obj.isMesh ) return '🔷';
  11. if ( obj.type === 'Group' ) return '📁';
  12. return '📦';
  13. }
  14. function createPropertyRow( label, value ) {
  15. const row = document.createElement( 'div' );
  16. row.className = 'property-row';
  17. row.style.display = 'flex';
  18. row.style.justifyContent = 'space-between';
  19. row.style.marginBottom = '2px';
  20. const labelSpan = document.createElement( 'span' );
  21. labelSpan.className = 'property-label';
  22. labelSpan.textContent = `${label}`;
  23. labelSpan.style.marginRight = '10px';
  24. labelSpan.style.whiteSpace = 'nowrap';
  25. const valueSpan = document.createElement( 'span' );
  26. valueSpan.className = 'property-value';
  27. const displayValue = ( value === undefined || value === null )
  28. ? '–'
  29. : ( typeof value === 'number' ? value.toLocaleString() : value );
  30. valueSpan.textContent = displayValue;
  31. valueSpan.style.textAlign = 'right';
  32. row.appendChild( labelSpan );
  33. row.appendChild( valueSpan );
  34. return row;
  35. }
  36. function createVectorRow( label, vector ) {
  37. const row = document.createElement( 'div' );
  38. row.className = 'property-row';
  39. row.style.marginBottom = '2px';
  40. // Pad label to ensure consistent alignment
  41. const paddedLabel = label.padEnd( 16, ' ' ); // Pad to 16 characters
  42. const content = `${paddedLabel} ${vector.x.toFixed( 3 )}\t${vector.y.toFixed( 3 )}\t${vector.z.toFixed( 3 )}`;
  43. row.textContent = content;
  44. row.style.fontFamily = 'monospace';
  45. row.style.whiteSpace = 'pre';
  46. return row;
  47. }
  48. // --- State ---
  49. const state = {
  50. revision: null,
  51. scenes: new Map(),
  52. renderers: new Map(),
  53. objects: new Map(),
  54. selectedObject: null
  55. };
  56. // Floating details panel
  57. let floatingPanel = null;
  58. let mousePosition = { x: 0, y: 0 };
  59. // Create a connection to the background page
  60. const backgroundPageConnection = chrome.runtime.connect( {
  61. name: CONNECTION_NAME
  62. } );
  63. // Initialize the connection with the inspected tab ID
  64. backgroundPageConnection.postMessage( {
  65. name: MESSAGE_INIT,
  66. tabId: chrome.devtools.inspectedWindow.tabId
  67. } );
  68. // Request the initial state from the bridge script
  69. backgroundPageConnection.postMessage( {
  70. name: MESSAGE_REQUEST_STATE,
  71. tabId: chrome.devtools.inspectedWindow.tabId
  72. } );
  73. // Function to scroll to canvas element
  74. function scrollToCanvas( rendererUuid ) {
  75. backgroundPageConnection.postMessage( {
  76. name: MESSAGE_SCROLL_TO_CANVAS,
  77. uuid: rendererUuid,
  78. tabId: chrome.devtools.inspectedWindow.tabId
  79. } );
  80. }
  81. const intervalId = setInterval( () => {
  82. backgroundPageConnection.postMessage( {
  83. name: MESSAGE_REQUEST_STATE,
  84. tabId: chrome.devtools.inspectedWindow.tabId
  85. } );
  86. }, STATE_POLLING_INTERVAL );
  87. backgroundPageConnection.onDisconnect.addListener( () => {
  88. clearInterval( intervalId );
  89. clearState();
  90. } );
  91. // Function to request object details from the bridge
  92. function requestObjectDetails( uuid ) {
  93. backgroundPageConnection.postMessage( {
  94. name: MESSAGE_REQUEST_OBJECT_DETAILS,
  95. uuid: uuid,
  96. tabId: chrome.devtools.inspectedWindow.tabId
  97. } );
  98. }
  99. // Function to highlight object in 3D scene
  100. function requestObjectHighlight( uuid ) {
  101. backgroundPageConnection.postMessage( {
  102. name: MESSAGE_HIGHLIGHT_OBJECT,
  103. uuid: uuid,
  104. tabId: chrome.devtools.inspectedWindow.tabId
  105. } );
  106. }
  107. // Function to remove highlight from 3D scene
  108. function requestObjectUnhighlight() {
  109. backgroundPageConnection.postMessage( {
  110. name: MESSAGE_UNHIGHLIGHT_OBJECT,
  111. tabId: chrome.devtools.inspectedWindow.tabId
  112. } );
  113. }
  114. // Store renderer collapse states
  115. const rendererCollapsedState = new Map();
  116. // Static DOM elements (created once in initUI)
  117. let renderersSection = null;
  118. let scenesSection = null;
  119. let sceneDirty = true;
  120. // Helper function to create properties column for renderer
  121. function createRendererPropertiesColumn( props ) {
  122. const propsCol = document.createElement( 'div' );
  123. propsCol.className = 'properties-column';
  124. const propsTitle = document.createElement( 'h4' );
  125. propsTitle.textContent = 'Properties';
  126. propsCol.appendChild( propsTitle );
  127. propsCol.appendChild( createPropertyRow( 'Size', `${props.width}x${props.height}` ) );
  128. propsCol.appendChild( createPropertyRow( 'Alpha', props.alpha ) );
  129. propsCol.appendChild( createPropertyRow( 'Antialias', props.antialias ) );
  130. propsCol.appendChild( createPropertyRow( 'Output Color Space', props.outputColorSpace ) );
  131. propsCol.appendChild( createPropertyRow( 'Tone Mapping', props.toneMapping ) );
  132. propsCol.appendChild( createPropertyRow( 'Tone Mapping Exposure', props.toneMappingExposure ) );
  133. propsCol.appendChild( createPropertyRow( 'Shadows', props.shadows ? 'enabled' : 'disabled' ) );
  134. propsCol.appendChild( createPropertyRow( 'Auto Clear', props.autoClear ) );
  135. propsCol.appendChild( createPropertyRow( 'Auto Clear Color', props.autoClearColor ) );
  136. propsCol.appendChild( createPropertyRow( 'Auto Clear Depth', props.autoClearDepth ) );
  137. propsCol.appendChild( createPropertyRow( 'Auto Clear Stencil', props.autoClearStencil ) );
  138. propsCol.appendChild( createPropertyRow( 'Local Clipping', props.localClipping ) );
  139. propsCol.appendChild( createPropertyRow( 'Physically Correct Lights', props.physicallyCorrectLights ) );
  140. return propsCol;
  141. }
  142. // Helper function to create stats column for renderer
  143. function createRendererStatsColumn( info ) {
  144. const statsCol = document.createElement( 'div' );
  145. statsCol.className = 'stats-column';
  146. // Render Stats
  147. const renderTitle = document.createElement( 'h4' );
  148. renderTitle.textContent = 'Render Stats';
  149. statsCol.appendChild( renderTitle );
  150. statsCol.appendChild( createPropertyRow( 'Frame', info.render.frame ) );
  151. statsCol.appendChild( createPropertyRow( 'Draw Calls', info.render.calls ) );
  152. statsCol.appendChild( createPropertyRow( 'Triangles', info.render.triangles ) );
  153. statsCol.appendChild( createPropertyRow( 'Points', info.render.points ) );
  154. statsCol.appendChild( createPropertyRow( 'Lines', info.render.lines ) );
  155. // Memory
  156. const memoryTitle = document.createElement( 'h4' );
  157. memoryTitle.textContent = 'Memory';
  158. memoryTitle.style.marginTop = '10px';
  159. statsCol.appendChild( memoryTitle );
  160. statsCol.appendChild( createPropertyRow( 'Geometries', info.memory.geometries ) );
  161. statsCol.appendChild( createPropertyRow( 'Textures', info.memory.textures ) );
  162. statsCol.appendChild( createPropertyRow( 'Shader Programs', info.memory.programs ) );
  163. return statsCol;
  164. }
  165. // Helper function to process scene batch updates
  166. function processSceneBatch( sceneUuid, batchObjects ) {
  167. // 1. Identify UUIDs in the new batch
  168. const newObjectUuids = new Set( batchObjects.map( obj => obj.uuid ) );
  169. // 2. Identify current object UUIDs associated with this scene that are NOT renderers
  170. const currentSceneObjectUuids = new Set();
  171. state.objects.forEach( ( obj, uuid ) => {
  172. // Use the _sceneUuid property we'll add below, or check if it's the scene root itself
  173. if ( obj._sceneUuid === sceneUuid || uuid === sceneUuid ) {
  174. currentSceneObjectUuids.add( uuid );
  175. }
  176. } );
  177. // 3. Find UUIDs to remove (in current state for this scene, but not in the new batch)
  178. const uuidsToRemove = new Set();
  179. currentSceneObjectUuids.forEach( uuid => {
  180. if ( ! newObjectUuids.has( uuid ) ) {
  181. uuidsToRemove.add( uuid );
  182. }
  183. } );
  184. // 4. Remove stale objects from state
  185. uuidsToRemove.forEach( uuid => {
  186. state.objects.delete( uuid );
  187. // If a scene object itself was somehow removed (unlikely for root), clean up scenes map too
  188. if ( state.scenes.has( uuid ) ) {
  189. state.scenes.delete( uuid );
  190. }
  191. } );
  192. // 5. Process the new batch: Add/Update objects and mark their scene association
  193. batchObjects.forEach( objData => {
  194. objData._sceneUuid = sceneUuid;
  195. state.objects.set( objData.uuid, objData );
  196. if ( objData.isScene && objData.uuid === sceneUuid ) {
  197. state.scenes.set( objData.uuid, objData );
  198. }
  199. } );
  200. sceneDirty = true;
  201. }
  202. // Clear state when panel is reloaded
  203. function clearState() {
  204. state.revision = null;
  205. state.scenes.clear();
  206. state.renderers.clear();
  207. state.objects.clear();
  208. sceneDirty = true;
  209. // Hide floating panel
  210. if ( floatingPanel ) {
  211. floatingPanel.classList.remove( 'visible' );
  212. }
  213. }
  214. // Listen for messages from the background page
  215. backgroundPageConnection.onMessage.addListener( function ( message ) {
  216. if ( message.id === MESSAGE_ID ) {
  217. handleThreeEvent( message );
  218. }
  219. } );
  220. function handleThreeEvent( message ) {
  221. switch ( message.name ) {
  222. case EVENT_REGISTER:
  223. state.revision = message.detail.revision;
  224. break;
  225. case EVENT_RENDERER:
  226. const detail = message.detail;
  227. state.renderers.set( detail.uuid, detail );
  228. state.objects.set( detail.uuid, detail );
  229. updateRenderers();
  230. break;
  231. case EVENT_OBJECT_DETAILS:
  232. state.selectedObject = message.detail;
  233. showFloatingDetails( message.detail );
  234. break;
  235. case EVENT_SCENE:
  236. const { sceneUuid, objects: batchObjects } = message.detail;
  237. processSceneBatch( sceneUuid, batchObjects );
  238. updateSceneTree();
  239. break;
  240. case EVENT_COMMITTED:
  241. clearState();
  242. updateRenderers();
  243. updateSceneTree();
  244. break;
  245. }
  246. }
  247. function renderRenderer( obj, container ) {
  248. // Create <details> element as the main container
  249. const detailsElement = document.createElement( 'details' );
  250. detailsElement.className = 'renderer-container';
  251. detailsElement.setAttribute( 'data-uuid', obj.uuid );
  252. // Set initial state
  253. detailsElement.open = rendererCollapsedState.get( obj.uuid ) || false;
  254. // Add toggle listener to save state
  255. detailsElement.addEventListener( 'toggle', () => {
  256. rendererCollapsedState.set( obj.uuid, detailsElement.open );
  257. } );
  258. // Create the summary element (clickable header) - THIS IS THE FIRST CHILD
  259. const summaryElem = document.createElement( 'summary' ); // USE <summary> tag
  260. summaryElem.className = 'tree-item renderer-summary'; // Acts as summary
  261. // Update display name in the summary line
  262. const props = obj.properties;
  263. const details = [ `${props.width}x${props.height}` ];
  264. if ( props.info ) {
  265. details.push( `${props.info.render.calls} draws` );
  266. details.push( `${props.info.render.triangles.toLocaleString()} triangles` );
  267. }
  268. const displayName = `${obj.type} <span class="object-details">${details.join( ' ・ ' )}</span>`;
  269. // Use toggle icon instead of paint icon
  270. const scrollButton = obj.canvasInDOM ?
  271. `<button class="scroll-to-canvas-btn" data-canvas-uuid="${obj.uuid}" title="Scroll to canvas">🙂</button>` :
  272. `<span class="scroll-to-canvas-placeholder" title="Canvas not in DOM">󠀠🫥</span>`;
  273. summaryElem.innerHTML = `<span class="icon toggle-icon"></span>
  274. <span class="label">${displayName}</span>
  275. <span class="type">${obj.type}</span>
  276. ${scrollButton}`;
  277. detailsElement.appendChild( summaryElem );
  278. const propsContainer = document.createElement( 'div' );
  279. propsContainer.className = 'properties-list';
  280. // Adjust padding calculation if needed, ensure it's a number before adding
  281. const summaryPaddingLeft = parseFloat( summaryElem.style.paddingLeft ) || 0;
  282. propsContainer.style.paddingLeft = `${summaryPaddingLeft + 20}px`; // Indent further
  283. propsContainer.innerHTML = ''; // Clear placeholder
  284. if ( obj.properties ) {
  285. const props = obj.properties;
  286. const info = props.info || { render: {}, memory: {} }; // Default empty objects if info is missing
  287. const gridContainer = document.createElement( 'div' );
  288. gridContainer.style.display = 'grid';
  289. gridContainer.style.gridTemplateColumns = 'repeat(auto-fit, minmax(200px, 1fr))'; // Responsive columns
  290. gridContainer.style.gap = '10px 20px'; // Row and column gap
  291. gridContainer.appendChild( createRendererPropertiesColumn( props ) );
  292. gridContainer.appendChild( createRendererStatsColumn( info ) );
  293. propsContainer.appendChild( gridContainer );
  294. } else {
  295. propsContainer.textContent = 'No properties available.';
  296. }
  297. detailsElement.appendChild( propsContainer );
  298. // Add click handler for scroll to canvas button
  299. const scrollBtn = detailsElement.querySelector( '.scroll-to-canvas-btn' );
  300. if ( scrollBtn ) {
  301. scrollBtn.addEventListener( 'click', ( event ) => {
  302. event.preventDefault();
  303. event.stopPropagation();
  304. scrollToCanvas( obj.uuid );
  305. } );
  306. }
  307. container.appendChild( detailsElement ); // Append details to the main container
  308. }
  309. // Function to render an object and its children
  310. function renderObject( obj, container, level = 0, parentInvisible = false ) {
  311. const icon = getObjectIcon( obj );
  312. let displayName = obj.name || obj.type;
  313. // Default rendering for other object types
  314. const elem = document.createElement( 'div' );
  315. elem.className = 'tree-item';
  316. elem.style.paddingLeft = `${level * 20}px`;
  317. elem.setAttribute( 'data-uuid', obj.uuid );
  318. // Apply opacity for invisible objects or if parent is invisible
  319. if ( obj.visible === false || parentInvisible ) {
  320. elem.style.opacity = '0.5';
  321. }
  322. let labelContent = `<span class="icon">${icon}</span>
  323. <span class="label">${displayName}</span>
  324. <span class="type">${obj.type}</span>`;
  325. if ( obj.isScene ) {
  326. // Add object count for scenes
  327. let objectCount = - 1;
  328. function countObjects( uuid ) {
  329. const object = state.objects.get( uuid );
  330. if ( object && object.name !== '__THREE_DEVTOOLS_HIGHLIGHT__' ) {
  331. objectCount ++; // Increment count for the object itself
  332. if ( object.children ) {
  333. object.children.forEach( childId => countObjects( childId ) );
  334. }
  335. }
  336. }
  337. countObjects( obj.uuid );
  338. displayName = `${obj.name || obj.type} <span class="object-details">${objectCount} objects</span>`;
  339. labelContent = `<span class="icon">${icon}</span>
  340. <span class="label">${displayName}</span>
  341. <span class="type">${obj.type}</span>`;
  342. }
  343. elem.innerHTML = labelContent;
  344. // Add mouseenter handler to request object details and highlight in 3D
  345. elem.addEventListener( 'mouseenter', () => {
  346. requestObjectDetails( obj.uuid );
  347. // Only highlight if object and all parents are visible
  348. if ( obj.visible !== false && ! parentInvisible ) {
  349. requestObjectHighlight( obj.uuid );
  350. }
  351. } );
  352. // Add mouseleave handler to remove 3D highlight
  353. elem.addEventListener( 'mouseleave', () => {
  354. requestObjectUnhighlight();
  355. } );
  356. container.appendChild( elem );
  357. // Handle children (excluding children of renderers, as properties are shown in details)
  358. if ( ! obj.isRenderer && obj.children && obj.children.length > 0 ) {
  359. // Create a container for children
  360. const childContainer = document.createElement( 'div' );
  361. childContainer.className = 'children';
  362. container.appendChild( childContainer );
  363. // Get all children and sort them by type for better organization
  364. const children = obj.children
  365. .map( childId => state.objects.get( childId ) )
  366. .filter( child => child !== undefined && child.name !== '__THREE_DEVTOOLS_HIGHLIGHT__' )
  367. .sort( ( a, b ) => {
  368. const getTypeOrder = ( obj ) => {
  369. if ( obj.isCamera ) return 1;
  370. if ( obj.isLight ) return 2;
  371. if ( obj.isGroup ) return 3;
  372. if ( obj.isMesh ) return 4;
  373. return 5;
  374. };
  375. const aOrder = getTypeOrder( a );
  376. const bOrder = getTypeOrder( b );
  377. return aOrder - bOrder;
  378. } );
  379. // Render each child
  380. children.forEach( child => {
  381. renderObject( child, childContainer, level + 1, parentInvisible || obj.visible === false );
  382. } );
  383. }
  384. }
  385. // Build the static DOM shell (called once)
  386. function initUI() {
  387. const container = document.getElementById( 'scene-tree' );
  388. const header = document.createElement( 'div' );
  389. header.className = 'header';
  390. header.style.display = 'flex';
  391. header.style.justifyContent = 'space-between';
  392. const miscSpan = document.createElement( 'span' );
  393. miscSpan.innerHTML = '<a href="https://docs.google.com/forms/d/e/1FAIpQLSdw1QcgXNiECYiPx6k0vSQRiRe0FmByrrojV4fgeL5zzXIiCw/viewform?usp=preview" target="_blank">+</a>';
  394. const manifest = chrome.runtime.getManifest();
  395. const manifestVersionSpan = document.createElement( 'span' );
  396. manifestVersionSpan.textContent = `${manifest.version}`;
  397. manifestVersionSpan.style.opacity = '0.5';
  398. header.appendChild( miscSpan );
  399. header.appendChild( manifestVersionSpan );
  400. container.appendChild( header );
  401. const sectionsContainer = document.createElement( 'div' );
  402. sectionsContainer.className = 'sections-container';
  403. container.appendChild( sectionsContainer );
  404. renderersSection = document.createElement( 'div' );
  405. renderersSection.className = 'section';
  406. renderersSection.style.display = 'none';
  407. sectionsContainer.appendChild( renderersSection );
  408. scenesSection = document.createElement( 'div' );
  409. scenesSection.className = 'section';
  410. scenesSection.style.display = 'none';
  411. sectionsContainer.appendChild( scenesSection );
  412. }
  413. // Update only the renderers section
  414. function updateRenderers() {
  415. if ( state.renderers.size > 0 ) {
  416. renderersSection.style.display = '';
  417. renderersSection.innerHTML = '<h3>Renderers</h3>';
  418. state.renderers.forEach( renderer => {
  419. renderRenderer( renderer, renderersSection );
  420. } );
  421. } else {
  422. renderersSection.style.display = 'none';
  423. }
  424. }
  425. // Rebuild the scene tree only when dirty
  426. function updateSceneTree() {
  427. if ( ! sceneDirty ) return;
  428. sceneDirty = false;
  429. if ( state.scenes.size > 0 ) {
  430. scenesSection.style.display = '';
  431. scenesSection.innerHTML = '<h3>Scenes</h3>';
  432. state.scenes.forEach( scene => {
  433. renderObject( scene, scenesSection );
  434. } );
  435. } else {
  436. scenesSection.style.display = 'none';
  437. }
  438. }
  439. // Create floating details panel
  440. function createFloatingPanel() {
  441. if ( floatingPanel ) return floatingPanel;
  442. floatingPanel = document.createElement( 'div' );
  443. floatingPanel.className = 'floating-details';
  444. document.body.appendChild( floatingPanel );
  445. return floatingPanel;
  446. }
  447. // Show floating details panel
  448. function showFloatingDetails( objectData ) {
  449. const panel = createFloatingPanel();
  450. // Clear previous content
  451. panel.innerHTML = '';
  452. if ( objectData.position ) {
  453. panel.appendChild( createVectorRow( 'Position', objectData.position ) );
  454. }
  455. if ( objectData.rotation ) {
  456. panel.appendChild( createVectorRow( 'Rotation', objectData.rotation ) );
  457. }
  458. if ( objectData.scale ) {
  459. panel.appendChild( createVectorRow( 'Scale', objectData.scale ) );
  460. }
  461. // Position panel near mouse
  462. updateFloatingPanelPosition();
  463. // Show panel
  464. panel.classList.add( 'visible' );
  465. }
  466. // Update floating panel position
  467. function updateFloatingPanelPosition() {
  468. if ( ! floatingPanel || ! floatingPanel.classList.contains( 'visible' ) ) return;
  469. const offset = 15; // Offset from cursor
  470. let x = mousePosition.x + offset;
  471. let y = mousePosition.y + offset;
  472. // Prevent panel from going off-screen
  473. const panelRect = floatingPanel.getBoundingClientRect();
  474. const maxX = window.innerWidth - panelRect.width - 10;
  475. const maxY = window.innerHeight - panelRect.height - 10;
  476. if ( x > maxX ) x = mousePosition.x - panelRect.width - offset;
  477. if ( y > maxY ) y = mousePosition.y - panelRect.height - offset;
  478. floatingPanel.style.left = `${Math.max( 10, x )}px`;
  479. floatingPanel.style.top = `${Math.max( 10, y )}px`;
  480. }
  481. // Track mouse position
  482. document.addEventListener( 'mousemove', ( event ) => {
  483. mousePosition.x = event.clientX;
  484. mousePosition.y = event.clientY;
  485. updateFloatingPanelPosition();
  486. } );
  487. // Hide panel when mouse leaves the tree area
  488. document.addEventListener( 'mouseover', ( event ) => {
  489. if ( floatingPanel && ! event.target.closest( '.tree-item' ) ) {
  490. floatingPanel.classList.remove( 'visible' );
  491. }
  492. } );
  493. // Initial UI setup
  494. initUI();
粤ICP备19079148号