Accueil / npm / three@0.186.1 / examples / jsm / inspector / tabs / Viewer.js

Viewer.js

import { Tab } from '../ui/Tab.js';
import { List } from '../ui/List.js';
import { Item } from '../ui/Item.js';
import { splitPath, splitCamelCase } from '../ui/utils.js';
import { getItem, setItem } from '../Inspector.js';

import { RendererUtils, NoToneMapping, LinearSRGBColorSpace, QuadMesh, NodeMaterial, CanvasTarget, Vector2, Color } from 'three/webgpu';
import { renderOutput, vec2, vec3, vec4, Fn, screenUV, step, OnMaterialUpdate, uniform, float } from 'three/tsl';

const _size = /*@__PURE__*/ new Vector2();

const splitIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="1" x2="12" y2="23"></line><polyline points="5 8 1 12 5 16"></polyline><polyline points="19 8 23 12 19 16"></polyline></svg>';
const fullscreenIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/></svg>';
const backIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>';

const aspectRatioUV = /*@__PURE__*/ Fn( ( [ uv, textureNode, canvasAspect ] ) => {

	const textureAspect = uniform( 0 );

	OnMaterialUpdate( () => {

		const { width, height } = textureNode.value;

		textureAspect.value = width / height;

	} );

	const ratio = canvasAspect.div( textureAspect );

	const centered = uv.sub( 0.5 );

	// If canvasAspect > textureAspect:
	const uvWide = vec2( centered.x.mul( ratio ), centered.y ).add( 0.5 );

	// If canvasAspect <= textureAspect:
	const uvTall = vec2( centered.x, centered.y.div( ratio ) ).add( 0.5 );

	const finalUV = canvasAspect.greaterThan( textureAspect ).select( uvWide, uvTall );

	const inBounds = step( 0.0, finalUV.x ).mul( step( finalUV.x, 1.0 ) ).mul( step( 0.0, finalUV.y ) ).mul( step( finalUV.y, 1.0 ) );

	return vec3( finalUV, inBounds );

} );

class Viewer extends Tab {

	constructor( options = {} ) {

		super( 'Viewer', options );

		this.content.style.overflow = 'hidden';
		this.maximizedByFullscreenButton = false;

		// Toolbar
		const toolbar = document.createElement( 'div' );
		toolbar.className = 'toolbar';

		const backBtn = document.createElement( 'button' );
		backBtn.className = 'viewer-back-btn';
		backBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"></line><polyline points="12 19 5 12 12 5"></polyline></svg>';
		backBtn.title = 'Back';
		backBtn.style.display = 'none';
		toolbar.appendChild( backBtn );

		const label = document.createElement( 'span' );
		label.textContent = 'View:';
		toolbar.appendChild( label );

		const select = document.createElement( 'select' );
		select.className = 'select';
		select.style.width = '200px';

		const defaultOption = document.createElement( 'option' );
		defaultOption.value = 'list';
		defaultOption.textContent = 'List';
		select.appendChild( defaultOption );

		toolbar.appendChild( select );

		const nodeList = new List( 'Viewer', 'Name' );
		nodeList.setGridStyle( '150px minmax(200px, 2fr)' );
		nodeList.domElement.style.minWidth = '400px';
		this.nodeList = nodeList;

		this.content.appendChild( toolbar );

		const scrollWrapper = document.createElement( 'div' );
		scrollWrapper.className = 'list-scroll-wrapper';
		scrollWrapper.style.flexGrow = '1';
		scrollWrapper.style.overflowY = 'auto';
		scrollWrapper.style.minHeight = '0';
		scrollWrapper.appendChild( nodeList.domElement );
		this.content.appendChild( scrollWrapper );

		// Container for full screen view
		const fullViewerContainer = document.createElement( 'div' );
		fullViewerContainer.className = 'full-viewer-container';
		fullViewerContainer.style.touchAction = 'none';
		this.content.appendChild( fullViewerContainer );

		const nodes = new Item( 'User Defined' );
		nodes.setCollapsible( true );
		nodeList.add( nodes );

		//

		this.itemLibrary = new Map();
		this.folderLibrary = new Map();
		this.canvasNodes = new Map();
		this.currentDataList = [];
		this.nodes = nodes;
		this.scrollWrapper = scrollWrapper;
		this.fullViewerContainer = fullViewerContainer;
		this.select = select;
		this.backBtn = backBtn;
		this.activeFullNodeId = null;
		this.pendingRestoreView = true;

		this.splitActive = false;
		this.splitCanvasData = null;
		this.splitOverlay = null;
		this.splitLine = null;
		this.splitQuad = null;
		this.splitMaterial = null;
		this.splitUniforms = null;
		this.splitCanvas = null;
		this.splitCanvasTarget = null;

		backBtn.addEventListener( 'click', () => {

			if ( this.splitActive ) {

				this.stopSplitMode();

			}

			select.value = 'list';
			this.showListView();

			if ( this.maximizedByFullscreenButton ) {

				if ( this.profiler && this.profiler.panel.classList.contains( 'maximized' ) ) {

					this.profiler.toggleMaximize();

				}

				this.maximizedByFullscreenButton = false;

			}

			this.saveLastView();

		} );

		select.addEventListener( 'change', () => {

			const val = select.value;

			if ( val === 'list' ) {

				this.showListView();

			} else {

				this.showNodeView( val );

			}

			this.saveLastView();

		} );

		// Event forwarding setup for OrbitControls
		this.isDraggingThumbnail = false;
		this.activeSourceCanvas = null;
		this.activePointerIds = new Set();

		const handleGlobalPointer = ( e ) => {

			if ( ! this.isDraggingThumbnail || ! this.activeSourceCanvas ) return;

			const renderer = this.inspector.getRenderer();

			if ( e.isForwarded ) return;

			// Block native event from reaching other document-level listeners (OrbitControls)
			e.stopImmediatePropagation();
			e.preventDefault();

			// Project and dispatch forwarded event
			this.forwardEvent( e, this.activeSourceCanvas, renderer.domElement );

			if ( e.type === 'pointerup' || e.type === 'pointercancel' ) {

				this.activePointerIds.delete( e.pointerId );

				if ( this.activePointerIds.size === 0 ) {

					this.isDraggingThumbnail = false;
					this.activeSourceCanvas = null;

				}

			}

		};

		window.addEventListener( 'pointermove', handleGlobalPointer, true );
		window.addEventListener( 'pointerup', handleGlobalPointer, true );
		window.addEventListener( 'pointercancel', handleGlobalPointer, true );

	}

	getFolder( name ) {

		let folder = this.folderLibrary.get( name );

		if ( folder === undefined ) {

			folder = new Item( name );
			folder.setCollapsible( true );

			this.folderLibrary.set( name, folder );
			this.nodeList.add( folder );

		}

		return folder;

	}

	hide() {

		super.hide();
		this.maximizedByFullscreenButton = false;
		this.isDraggingThumbnail = false;
		this.activeSourceCanvas = null;
		this.activePointerIds.clear();

		if ( this.splitActive ) {

			this.stopSplitMode();

		}

	}

	addNodeItem( canvasData ) {

		let item = this.itemLibrary.get( canvasData.id );

		if ( item === undefined ) {

			const name = canvasData.name;
			const domElement = canvasData.canvasTarget.domElement;

			// Create wrapper
			const wrapper = document.createElement( 'div' );
			wrapper.className = 'node-canvas-wrapper';
			wrapper.style.position = 'relative';
			wrapper.style.display = 'inline-block';
			wrapper.style.width = '140px';
			wrapper.style.height = '140px';
			wrapper.style.touchAction = 'none';

			// View full screen button
			const viewBtn = document.createElement( 'button' );
			viewBtn.className = 'node-canvas-detach-btn';
			viewBtn.title = 'View full size';
			viewBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="7" y1="17" x2="17" y2="7"></line><polyline points="7 7 17 7 17 17"></polyline></svg>';

			viewBtn.onclick = ( e ) => {

				e.stopPropagation();

				if ( this.splitActive ) {

					this.stopSplitMode();

				}

				this.select.value = canvasData.id;
				this.showNodeView( canvasData.id );
				this.saveLastView();

			};

			// Fullscreen and maximize button
			const fullscreenBtn = document.createElement( 'button' );
			fullscreenBtn.className = 'node-canvas-fullscreen-btn';
			fullscreenBtn.title = 'Fullscreen view';
			fullscreenBtn.innerHTML = fullscreenIcon;

			fullscreenBtn.onclick = ( e ) => {

				e.stopPropagation();

				if ( this.splitActive && this.splitCanvasData === canvasData && this.splitFullscreen ) {

					this.stopSplitMode();

				} else {

					this.splitFullscreen = true;
					this.startSplitMode( canvasData );

				}

			};

			// Split screen button
			const splitBtn = document.createElement( 'button' );
			splitBtn.className = 'node-canvas-split-btn';
			splitBtn.title = 'Interactive split screen';
			splitBtn.innerHTML = splitIcon;

			splitBtn.onclick = ( e ) => {

				e.stopPropagation();

				if ( this.splitActive && this.splitCanvasData === canvasData && ! this.splitFullscreen ) {

					this.stopSplitMode();

				} else {

					this.splitFullscreen = false;
					this.startSplitMode( canvasData );

				}

			};

			wrapper.appendChild( domElement );
			wrapper.appendChild( viewBtn );
			wrapper.appendChild( fullscreenBtn );
			wrapper.appendChild( splitBtn );

			this.setupEventForwarding( domElement );

			// Store elements in canvasData for access
			canvasData.domElement = domElement;
			canvasData.wrapperElement = wrapper;
			canvasData.splitBtn = splitBtn;
			canvasData.fullscreenBtn = fullscreenBtn;

			item = new Item( wrapper, name );
			item.itemRow.children[ 1 ].style[ 'justify-content' ] = 'flex-start';
			this.itemLibrary.set( canvasData.id, item );

		}

		return item;

	}

	setupEventForwarding( sourceCanvas ) {

		sourceCanvas.style.touchAction = 'none';

		const onPointerDown = ( e ) => {

			const renderer = this.inspector.getRenderer();

			if ( ! renderer || ! renderer.domElement ) return;

			const targetCanvas = renderer.domElement;

			this.isDraggingThumbnail = true;
			this.activeSourceCanvas = sourceCanvas;
			this.activePointerIds.add( e.pointerId );

			// Project and dispatch pointerdown
			this.forwardEvent( e, sourceCanvas, targetCanvas );

		};

		sourceCanvas.addEventListener( 'pointerdown', onPointerDown );

		// Wheel event support for zooming
		const onWheel = ( e ) => {

			const renderer = this.inspector.getRenderer();

			if ( ! renderer || ! renderer.domElement ) return;

			e.preventDefault();
			e.stopPropagation();

			this.forwardEvent( e, sourceCanvas, renderer.domElement );

		};

		sourceCanvas.addEventListener( 'wheel', onWheel, { passive: false } );

		// Click, dblclick, contextmenu
		const onMouseShortcut = ( e ) => {

			const renderer = this.inspector.getRenderer();

			if ( ! renderer || ! renderer.domElement ) return;

			e.stopPropagation();

			if ( e.type === 'contextmenu' ) {

				e.preventDefault();

			}

			this.forwardEvent( e, sourceCanvas, renderer.domElement );

		};

		sourceCanvas.addEventListener( 'click', onMouseShortcut );
		sourceCanvas.addEventListener( 'dblclick', onMouseShortcut );
		sourceCanvas.addEventListener( 'contextmenu', onMouseShortcut );

	}

	forwardEvent( event, sourceCanvas, targetCanvas ) {

		const sourceRect = sourceCanvas.getBoundingClientRect();
		const targetRect = targetCanvas.getBoundingClientRect();

		const localX = ( event.clientX - sourceRect.left ) / sourceRect.width;
		const localY = ( event.clientY - sourceRect.top ) / sourceRect.height;

		const targetClientX = targetRect.left + localX * targetRect.width;
		const targetClientY = targetRect.top + localY * targetRect.height;
		const targetPageX = targetClientX + window.scrollX;
		const targetPageY = targetClientY + window.scrollY;

		let newEvent;

		const eventInit = {
			bubbles: true,
			cancelable: true,
			view: window,
			clientX: targetClientX,
			clientY: targetClientY,
			screenX: targetClientX + window.screenX,
			screenY: targetClientY + window.screenY,
			pageX: targetPageX,
			pageY: targetPageY,
			ctrlKey: event.ctrlKey,
			shiftKey: event.shiftKey,
			altKey: event.altKey,
			metaKey: event.metaKey,
			buttons: event.buttons,
			button: event.button
		};

		if ( event instanceof WheelEvent ) {

			newEvent = new WheelEvent( event.type, {
				...eventInit,
				deltaX: event.deltaX,
				deltaY: event.deltaY,
				deltaZ: event.deltaZ,
				deltaMode: event.deltaMode
			} );

		} else if ( window.PointerEvent && event instanceof PointerEvent ) {

			newEvent = new PointerEvent( event.type, {
				...eventInit,
				pointerId: event.pointerId,
				width: event.width,
				height: event.height,
				pressure: event.pressure,
				tiltX: event.tiltX,
				tiltY: event.tiltY,
				pointerType: event.pointerType,
				isPrimary: event.isPrimary
			} );

		} else {

			newEvent = new MouseEvent( event.type, eventInit );

		}

		newEvent.isForwarded = true;
		targetCanvas.dispatchEvent( newEvent );

	}

	init( inspector ) {

		super.init( inspector );

		const updateLayoutFromPosition = () => {

			const position = inspector.profiler.position;
			const isHorizontal = position === 'top' || position === 'bottom';
			this.setViewMode( isHorizontal ? 'grid' : 'list' );

		};

		inspector.profiler.addEventListener( 'resize', updateLayoutFromPosition );

		// Run initially once
		updateLayoutFromPosition();

	}

	setViewMode( mode ) {

		if ( this.nodeList ) {

			if ( mode === 'grid' ) {

				this.nodeList.domElement.style.minWidth = '0';

			} else {

				this.nodeList.domElement.style.minWidth = '400px';

			}

			this.nodeList.setViewMode( mode );

		}

	}

	showListView() {

		if ( this.activeFullNodeId ) {

			const canvasData = Array.from( this.canvasNodes.values() ).find( data => String( data.id ) === String( this.activeFullNodeId ) );

			if ( canvasData ) {

				// Move canvas back to wrapper
				canvasData.wrapperElement.appendChild( canvasData.domElement );

				// Reset size
				canvasData.domElement.style.width = '';
				canvasData.domElement.style.height = '';
				canvasData.canvasTarget.setSize( 140, 140 );

				const renderer = this.inspector.getRenderer();

				renderer.backend.delete( canvasData.canvasTarget );

			}

			this.activeFullNodeId = null;

		}

		this.scrollWrapper.style.display = '';
		this.fullViewerContainer.style.display = 'none';
		this.backBtn.style.display = 'none';

	}

	showNodeView( nodeId ) {

		// First restore previous full screen node if any
		if ( this.activeFullNodeId && String( this.activeFullNodeId ) !== String( nodeId ) ) {

			this.showListView();

		}

		const canvasData = Array.from( this.canvasNodes.values() ).find( data => String( data.id ) === String( nodeId ) );

		if ( canvasData ) {

			this.addNodeItem( canvasData );

			this.activeFullNodeId = nodeId;
			this.backBtn.style.display = 'flex';

			// Hide list, show full screen container
			this.scrollWrapper.style.display = 'none';
			this.fullViewerContainer.style.display = 'flex';

			// Move canvas to the full viewer container
			this.fullViewerContainer.appendChild( canvasData.domElement );
			canvasData.domElement.style.width = '100%';
			canvasData.domElement.style.height = '100%';

			// Resize canvas to fit full viewer container
			const rect = this.fullViewerContainer.getBoundingClientRect();
			const contentWidth = rect.width || this.content.clientWidth;
			const contentHeight = rect.height || ( this.content.clientHeight - 38 ); // minus toolbar
			canvasData.canvasTarget.setSize( contentWidth, contentHeight );

			const renderer = this.inspector.getRenderer();

			renderer.backend.delete( canvasData.canvasTarget );

		}

	}

	get isActive() {

		if ( this.splitActive ) return true;

		return super.isActive;

	}

	set isActive( value ) {

		super.isActive = value;

	}

	startSplitMode( canvasData ) {

		if ( this.profiler && this.profiler.panel.classList.contains( 'visible' ) ) {

			this.profiler.togglePanel();

		}

		this.splitActive = true;
		this.splitCanvasData = canvasData;
		this.splitX = 0.5;

		const renderer = this.inspector.getRenderer();
		const mainCanvas = renderer.domElement;
		const rect = mainCanvas.getBoundingClientRect();
		const targetParent = document.fullscreenElement || this.profiler.domElement;
		const parentRect = targetParent.getBoundingClientRect();
		const localLeft = rect.left - parentRect.left;
		const localTop = rect.top - parentRect.top;

		// Position target canvas on top of main canvas
		if ( ! this.splitCanvas ) {

			this.splitCanvas = document.createElement( 'canvas' );
			this.splitCanvas.style.position = 'absolute';
			this.splitCanvas.style.pointerEvents = 'none';
			this.splitCanvas.style.zIndex = '998';

			this.splitCanvasTarget = new CanvasTarget( this.splitCanvas );
			this.splitCanvasTarget.setPixelRatio( window.devicePixelRatio );

		}

		this.splitCanvas.style.left = `${ localLeft }px`;
		this.splitCanvas.style.top = `${ localTop }px`;
		this.splitCanvas.style.width = `${ rect.width }px`;
		this.splitCanvas.style.height = `${ rect.height }px`;

		this.splitCanvasTarget.setSize( rect.width, rect.height );
		renderer.backend.delete( this.splitCanvasTarget );

		targetParent.appendChild( this.splitCanvas );

		// Overlay divider line (only in split/non-fullscreen mode)
		if ( ! this.splitFullscreen ) {

			if ( ! this.splitOverlay ) {

				const overlay = document.createElement( 'div' );
				overlay.className = 'split-screen-overlay three-inspector';

				const line = document.createElement( 'div' );
				line.className = 'split-screen-line';

				overlay.appendChild( line );

				let isDragging = false;

				const onPointerDown = ( e ) => {

					isDragging = true;
					line.classList.add( 'active' );
					e.preventDefault();

				};

				const onPointerMove = ( e ) => {

					if ( ! isDragging ) return;

					const r = mainCanvas.getBoundingClientRect();
					const localX = e.clientX - r.left;
					const pct = Math.max( 0, Math.min( 1, localX / r.width ) );
					this.splitX = pct;
					line.style.left = `${ pct * 100 }%`;

					if ( this.splitUniforms && this.splitUniforms.splitX ) {

						this.splitUniforms.splitX.value = pct;

					}

				};

				const onPointerUp = () => {

					isDragging = false;
					line.classList.remove( 'active' );

				};

				line.addEventListener( 'pointerdown', onPointerDown );
				window.addEventListener( 'pointermove', onPointerMove );
				window.addEventListener( 'pointerup', onPointerUp );

				this.splitOverlay = overlay;
				this.splitLine = line;

			}

			this.splitOverlay.style.left = `${ localLeft }px`;
			this.splitOverlay.style.top = `${ localTop }px`;
			this.splitOverlay.style.width = `${ rect.width }px`;
			this.splitOverlay.style.height = `${ rect.height }px`;
			this.splitLine.style.left = '50%';

			targetParent.appendChild( this.splitOverlay );

		} else {

			// If we transitioned from split to fullscreen, remove the overlay slider
			if ( this.splitOverlay && this.splitOverlay.parentElement ) {

				this.splitOverlay.parentElement.removeChild( this.splitOverlay );

			}

		}

		if ( ! this.splitUniforms ) {

			this.splitUniforms = {
				splitX: uniform( 0.5 ),
				viewportWidth: uniform( rect.width )
			};

		}

		this.splitUniforms.splitX.value = 0.5;
		this.splitUniforms.viewportWidth.value = rect.width;

		// Recreate or setup material for split screen comparison
		const node = canvasData.node;
		const target = node.context( { getUV: () => screenUV } );
		const targetColor = renderOutput( vec4( vec3( target ), 1 ), NoToneMapping, renderer.outputColorSpace );
		const targetColorCtx = targetColor.context( { inspector: true } );

		let finalColor;

		if ( this.splitFullscreen ) {

			finalColor = targetColorCtx;

		} else {

			finalColor = Fn( () => {

				screenUV.x.lessThan( this.splitUniforms.splitX ).discard();

				return targetColorCtx;

			} )();

		}

		if ( this.splitMaterial ) {

			this.splitMaterial.dispose();

		}

		this.splitMaterial = new NodeMaterial();
		this.splitMaterial.outputNode = finalColor;
		this.splitMaterial.depthTest = false;
		this.splitMaterial.depthWrite = false;

		if ( this.splitQuad ) {

			this.splitQuad.material = this.splitMaterial;

		} else {

			this.splitQuad = new QuadMesh( this.splitMaterial );

		}

		this.updateSplitButtonsState();

	}

	stopSplitMode() {

		this.splitActive = false;
		this.splitCanvasData = null;
		this.splitFullscreen = false;

		if ( this.splitOverlay && this.splitOverlay.parentElement ) {

			this.splitOverlay.parentElement.removeChild( this.splitOverlay );

		}

		if ( this.splitCanvas && this.splitCanvas.parentElement ) {

			this.splitCanvas.parentElement.removeChild( this.splitCanvas );

		}

		this.updateSplitButtonsState();

	}

	updateSplitButtonsState() {

		if ( this.splitActive ) {

			this.backBtn.style.display = '';

		} else {

			if ( this.select.value === 'list' ) {

				this.backBtn.style.display = 'none';

			} else {

				this.backBtn.style.display = '';

			}

		}

		for ( const canvasData of this.canvasNodes.values() ) {

			if ( ! canvasData.splitBtn || ! canvasData.fullscreenBtn ) continue;

			const isSelected = this.splitActive && this.splitCanvasData === canvasData;

			if ( isSelected ) {

				if ( this.splitFullscreen ) {

					canvasData.fullscreenBtn.classList.add( 'active' );
					canvasData.fullscreenBtn.innerHTML = backIcon;
					canvasData.fullscreenBtn.title = 'Exit Fullscreen';

					canvasData.splitBtn.classList.remove( 'active' );
					canvasData.splitBtn.innerHTML = splitIcon;
					canvasData.splitBtn.title = 'Interactive split screen';

				} else {

					canvasData.splitBtn.classList.add( 'active' );
					canvasData.splitBtn.innerHTML = backIcon;
					canvasData.splitBtn.title = 'Exit Split Screen';

					canvasData.fullscreenBtn.classList.remove( 'active' );
					canvasData.fullscreenBtn.innerHTML = fullscreenIcon;
					canvasData.fullscreenBtn.title = 'Fullscreen view';

				}

			} else {

				canvasData.splitBtn.classList.remove( 'active' );
				canvasData.splitBtn.innerHTML = splitIcon;
				canvasData.splitBtn.title = 'Interactive split screen';

				canvasData.fullscreenBtn.classList.remove( 'active' );
				canvasData.fullscreenBtn.innerHTML = fullscreenIcon;
				canvasData.fullscreenBtn.title = 'Fullscreen view';

			}

		}

	}

	getCanvasDataByNode( renderer, node ) {

		let canvasData = this.canvasNodes.get( node );

		if ( canvasData === undefined ) {

			const canvas = document.createElement( 'canvas' );

			const canvasTarget = new CanvasTarget( canvas );
			canvasTarget.setPixelRatio( window.devicePixelRatio );
			canvasTarget.setSize( 140, 140 );

			const id = node.id;

			const { path, name } = splitPath( splitCamelCase( node.getName() || '(unnamed)' ) );

			const canvasAspect = uniform( 1 );
			const mask = float( 1 );

			const target = node.context( { getUV: ( textureNode ) => {

				const uvData = aspectRatioUV( screenUV, textureNode, canvasAspect );
				const correctedUV = uvData.xy;
				mask.assign( uvData.z );

				return correctedUV;

			} } );

			let output = vec4( vec3( target ), 1 ).mul( mask );
			output = renderOutput( output, NoToneMapping, renderer.outputColorSpace );
			output = output.context( { inspector: true } );

			const material = new NodeMaterial();
			material.outputNode = output;

			const quad = new QuadMesh( material );
			quad.name = 'Viewer - ' + name;

			canvasData = {
				id,
				name,
				path,
				node,
				quad,
				canvasTarget,
				material,
				canvasAspect
			};

			this.canvasNodes.set( node, canvasData );

		}

		return canvasData;

	}

	update( inspector ) {

		const renderer = inspector.getRenderer();

		if ( this.splitActive ) {

			const mainCanvas = renderer.domElement;
			const rect = mainCanvas.getBoundingClientRect();

			if ( rect.width <= 0 || rect.height <= 0 ) {

				return;

			}

			const targetParent = document.fullscreenElement || this.profiler.domElement;
			const parentRect = targetParent.getBoundingClientRect();
			const localLeft = rect.left - parentRect.left;
			const localTop = rect.top - parentRect.top;

			if ( this.splitCanvas.parentElement !== targetParent ) {

				targetParent.appendChild( this.splitCanvas );

			}

			if ( this.splitOverlay && ! this.splitFullscreen && this.splitOverlay.parentElement !== targetParent ) {

				targetParent.appendChild( this.splitOverlay );

			}

			this.splitCanvas.style.left = `${ localLeft }px`;
			this.splitCanvas.style.top = `${ localTop }px`;
			this.splitCanvas.style.width = `${ rect.width }px`;
			this.splitCanvas.style.height = `${ rect.height }px`;

			if ( this.splitOverlay && ! this.splitFullscreen ) {

				this.splitOverlay.style.left = `${ localLeft }px`;
				this.splitOverlay.style.top = `${ localTop }px`;
				this.splitOverlay.style.width = `${ rect.width }px`;
				this.splitOverlay.style.height = `${ rect.height }px`;

			}

			if ( this.splitCanvasTarget.width !== rect.width || this.splitCanvasTarget.height !== rect.height ) {

				this.splitCanvasTarget.setSize( rect.width, rect.height );

				if ( this.splitUniforms && this.splitUniforms.viewportWidth ) {

					this.splitUniforms.viewportWidth.value = rect.width;

				}

				renderer.backend.delete( this.splitCanvasTarget );

			}

			// Render the split quad to the splitCanvasTarget
			const previousCanvasTarget = renderer.getCanvasTarget();
			const previousClearColor = renderer.getClearColor( new Color() );
			const previousClearAlpha = renderer.getClearAlpha();

			const state = RendererUtils.resetRendererState( renderer );

			renderer.toneMapping = NoToneMapping;
			renderer.outputColorSpace = LinearSRGBColorSpace;

			renderer.setCanvasTarget( this.splitCanvasTarget );
			renderer.setClearColor( 0x000000, 0 );

			this.splitQuad.render( renderer );

			renderer.setCanvasTarget( previousCanvasTarget );
			renderer.setClearColor( previousClearColor, previousClearAlpha );

			RendererUtils.restoreRendererState( renderer, state );

		}

		const nodes = inspector.getNodes();

		if ( nodes.length > 0 ) {

			if ( ! renderer.backend.isWebGPUBackend ) {

				// Viewer is only available with WebGPU.

				return;

			}

			if ( ! this.isVisible ) {

				this.show();

			}

		}

		if ( ! this.isActive ) return;

		const canvasDataList = nodes.map( node => this.getCanvasDataByNode( renderer, node ) );

		// Check if the list of nodes has changed
		let nodesChanged = canvasDataList.length !== this.currentDataList.length;

		if ( ! nodesChanged ) {

			for ( let i = 0; i < canvasDataList.length; i ++ ) {

				if ( canvasDataList[ i ].id !== this.currentDataList[ i ].id ) {

					nodesChanged = true;
					break;

				}

			}

		}

		if ( nodesChanged ) {

			const currentSelectedValue = this.select.value;

			// Clear options except the first one ('list')
			while ( this.select.options.length > 1 ) {

				this.select.remove( 1 );

			}

			// Add options for each node in canvasDataList
			for ( const canvasData of canvasDataList ) {

				const option = document.createElement( 'option' );
				option.value = canvasData.id;
				option.textContent = canvasData.path ? `${ canvasData.path } / ${ canvasData.name }` : canvasData.name;
				this.select.appendChild( option );

			}

			// Try to restore from saved view first on initial load
			let restored = false;

			if ( this.pendingRestoreView ) {

				const savedView = getItem( 'viewerLastView' );

				if ( savedView !== 'list' ) {

					for ( let i = 0; i < this.select.options.length; i ++ ) {

						if ( this.select.options[ i ].textContent === savedView ) {

							this.select.selectedIndex = i;
							const nodeId = this.select.options[ i ].value;
							this.showNodeView( nodeId );
							restored = true;
							this.pendingRestoreView = false;
							break;

						}

					}

				} else {

					this.pendingRestoreView = false;

				}

			}

			if ( ! restored ) {

				// Restore selection if still valid
				let hasSelectedValue = false;

				for ( let i = 0; i < this.select.options.length; i ++ ) {

					if ( this.select.options[ i ].value === currentSelectedValue ) {

						this.select.selectedIndex = i;
						hasSelectedValue = true;
						break;

					}

				}

				if ( ! hasSelectedValue ) {

					this.select.value = 'list';
					this.showListView();

				}

			}

		}

		// Real-time resize of active full-screen node canvas target
		if ( this.activeFullNodeId ) {

			const canvasData = canvasDataList.find( data => String( data.id ) === String( this.activeFullNodeId ) );

			if ( canvasData ) {

				const rect = this.fullViewerContainer.getBoundingClientRect();
				const contentWidth = rect.width || this.content.clientWidth;
				const contentHeight = rect.height || ( this.content.clientHeight - 38 );

				if ( canvasData.canvasTarget.domElement.width !== contentWidth || canvasData.canvasTarget.domElement.height !== contentHeight ) {

					canvasData.canvasTarget.setSize( contentWidth, contentHeight );

					renderer.backend.delete( canvasData.canvasTarget );

				}

			}

		}

		//

		const previousDataList = [ ...this.currentDataList ];

		// remove old

		for ( const canvasData of previousDataList ) {

			if ( this.itemLibrary.has( canvasData.id ) && canvasDataList.indexOf( canvasData ) === - 1 ) {

				const item = this.itemLibrary.get( canvasData.id );
				const parent = item.parent;

				parent.remove( item );

				if ( this.folderLibrary.has( parent.data[ 0 ] ) && parent.children.length === 0 ) {

					parent.parent.remove( parent );

					this.folderLibrary.delete( parent.data[ 0 ] );

				}

				this.itemLibrary.delete( canvasData.id );

			}

		}

		//

		const indexes = {};

		for ( const canvasData of canvasDataList ) {

			const item = this.addNodeItem( canvasData );
			const previousCanvasTarget = renderer.getCanvasTarget();

			const path = canvasData.path;

			if ( path ) {

				const folder = this.getFolder( path );

				if ( indexes[ path ] === undefined ) {

					indexes[ path ] = 0;

				}

				if ( folder.parent === null || item.parent !== folder || folder.children.indexOf( item ) !== indexes[ path ] ) {

					folder.add( item );

				}

				indexes[ path ] ++;

			} else {

				if ( ! item.parent ) {

					this.nodes.add( item );

				}

			}

			const rttNodes = [];

			const mainSize = previousCanvasTarget.getDrawingBufferSize( _size );

			canvasData.node.traverse( ( child ) => {

				if ( child.isRTTNode && child.autoResize === true ) {

					const oldWidth = child.width;
					const oldHeight = child.height;

					child.width = mainSize.width;
					child.height = mainSize.height;

					child.setSize( mainSize.width, mainSize.height );

					rttNodes.push( {
						node: child,
						oldWidth,
						oldHeight
					} );

				}

			} );

			const state = RendererUtils.resetRendererState( renderer );

			renderer.toneMapping = NoToneMapping;
			renderer.outputColorSpace = LinearSRGBColorSpace;

			renderer.setCanvasTarget( canvasData.canvasTarget );

			if ( canvasData.canvasAspect ) {

				canvasData.canvasAspect.value = canvasData.canvasTarget.domElement.width / canvasData.canvasTarget.domElement.height;

			}

			canvasData.quad.render( renderer );

			renderer.setCanvasTarget( previousCanvasTarget );

			RendererUtils.restoreRendererState( renderer, state );

			for ( const rtt of rttNodes ) {

				rtt.node.width = rtt.oldWidth;
				rtt.node.height = rtt.oldHeight;

			}

		}

		this.currentDataList = canvasDataList;

	}

	setActive( isActive ) {

		super.setActive( isActive );

	}

	saveLastView() {

		const selectedValue = this.select.value;

		if ( selectedValue === 'list' ) {

			setItem( 'viewerLastView', 'list' );

		} else {

			const selectedOption = this.select.options[ this.select.selectedIndex ];

			if ( selectedOption ) {

				setItem( 'viewerLastView', selectedOption.textContent );

			}

		}

	}

	dispose() {

		if ( this.splitActive ) {

			this.stopSplitMode();

		}

		if ( this.splitCanvas ) {

			if ( this.splitCanvas.parentElement ) {

				this.splitCanvas.parentElement.removeChild( this.splitCanvas );

			}

			this.splitCanvas = null;

		}

		if ( this.splitCanvasTarget ) {

			this.splitCanvasTarget.dispose();
			this.splitCanvasTarget = null;

		}

		if ( this.splitMaterial ) {

			this.splitMaterial.dispose();
			this.splitMaterial = null;

		}

		for ( const canvasData of this.canvasNodes.values() ) {

			canvasData.canvasTarget.dispose();
			canvasData.material.dispose();

		}

		this.canvasNodes.clear();

		super.dispose();

	}

}

export { Viewer };