lil-gui 0.11.0 → 0.16.0

Created Diff never expires
232 removals
Lines
Total
Removed
Words
Total
Removed
To continue using this feature, upgrade to
Diffchecker logo
Diffchecker Pro
1069 lines
215 additions
Lines
Total
Added
Words
Total
Added
To continue using this feature, upgrade to
Diffchecker logo
Diffchecker Pro
1054 lines
/**
/**
* lil-gui
* lil-gui
* https://lil-gui.georgealways.com
* https://lil-gui.georgealways.com
* @version 0.11.0
* @version 0.16.0
* @author George Michael Brower
* @author George Michael Brower
* @license MIT
* @license MIT
*/
*/


/**
/**
* Base class for all controllers.
* Base class for all controllers.
*/
*/
class Controller {
class Controller {


constructor( parent, object, property, className, widgetTag = 'div' ) {
constructor( parent, object, property, className, widgetTag = 'div' ) {


/**
/**
* The GUI that contains this controller.
* The GUI that contains this controller.
* @type {GUI}
* @type {GUI}
*/
*/
this.parent = parent;
this.parent = parent;


/**
/**
* The object this controller will modify.
* The object this controller will modify.
* @type {object}
* @type {object}
*/
*/
this.object = object;
this.object = object;


/**
/**
* The name of the property to control.
* The name of the property to control.
* @type {string}
* @type {string}
*/
*/
this.property = property;
this.property = property;


/**
/**
* Used to determine if the controller is disabled.
* Used to determine if the controller is disabled.
* Use `controller.disable( true|false )` to modify this value
* Use `controller.disable( true|false )` to modify this value
* @type {boolean}
* @type {boolean}
*/
*/
this._disabled = false;
this._disabled = false;


/**
/**
* The value of `object[ property ]` when the controller was created.
* The value of `object[ property ]` when the controller was created.
* @type {any}
* @type {any}
*/
*/
this.initialValue = this.getValue();
this.initialValue = this.getValue();


/**
/**
* The outermost container DOM element for this controller.
* The outermost container DOM element for this controller.
* @type {HTMLElement}
* @type {HTMLElement}
*/
*/
this.domElement = document.createElement( 'div' );
this.domElement = document.createElement( 'div' );
this.domElement.classList.add( 'controller' );
this.domElement.classList.add( 'controller' );
this.domElement.classList.add( className );
this.domElement.classList.add( className );


/**
/**
* The DOM element that contains the controller's name.
* The DOM element that contains the controller's name.
* @type {HTMLElement}
* @type {HTMLElement}
*/
*/
this.$name = document.createElement( 'div' );
this.$name = document.createElement( 'div' );
this.$name.classList.add( 'name' );
this.$name.classList.add( 'name' );


Controller.nextNameID = Controller.nextNameID || 0;
Controller.nextNameID = Controller.nextNameID || 0;
this.$name.id = `lil-gui-name-${++Controller.nextNameID}`;
this.$name.id = `lil-gui-name-${++Controller.nextNameID}`;


/**
/**
* The DOM element that contains the controller's "widget" (which differs by controller type).
* The DOM element that contains the controller's "widget" (which differs by controller type).
* @type {HTMLElement}
* @type {HTMLElement}
*/
*/
this.$widget = document.createElement( widgetTag );
this.$widget = document.createElement( widgetTag );
this.$widget.classList.add( 'widget' );
this.$widget.classList.add( 'widget' );


/**
/**
* The DOM element that receives the disabled attribute when using disable()
* The DOM element that receives the disabled attribute when using disable()
* @type {HTMLElement}
* @type {HTMLElement}
*/
*/
this.$disable = this.$widget;
this.$disable = this.$widget;


this.domElement.appendChild( this.$name );
this.domElement.appendChild( this.$name );
this.domElement.appendChild( this.$widget );
this.domElement.appendChild( this.$widget );


this.parent.children.push( this );
this.parent.children.push( this );
this.parent.controllers.push( this );
this.parent.controllers.push( this );


this.parent.$children.appendChild( this.domElement );
this.parent.$children.appendChild( this.domElement );


this._listenCallback = this._listenCallback.bind( this );
this._listenCallback = this._listenCallback.bind( this );


this.name( property );
this.name( property );


}
}


/**
/**
* Sets the name of the controller and its label in the GUI.
* Sets the name of the controller and its label in the GUI.
* @param {string} name
* @param {string} name
* @returns {this}
* @returns {this}
*/
*/
name( name ) {
name( name ) {
/**
/**
* The controller's name. Use `controller.name( 'Name' )` to modify this value.
* The controller's name. Use `controller.name( 'Name' )` to modify this value.
* @type {string}
* @type {string}
*/
*/
this._name = name;
this._name = name;
this.$name.innerHTML = name;
this.$name.innerHTML = name;
return this;
return this;
}
}


/**
/**
* Pass a function to be called whenever the value is modified by this controller.
* Pass a function to be called whenever the value is modified by this controller.
* The function receives the new value as its first parameter. The value of `this` will be the
* The function receives the new value as its first parameter. The value of `this` will be the
* controller.
* controller.
* @param {Function} callback
* @param {Function} callback
* @returns {this}
* @returns {this}
* @example
* @example
* const controller = gui.add( object, 'property' );
* const controller = gui.add( object, 'property' );
*
*
* controller.onChange( v => {
* controller.onChange( function( v ) {
* console.log( 'The value is now ' + v );
* console.log( 'The value is now ' + v );
* console.assert( this === controller );
* console.assert( this === controller );
* } );
* } );
*/
*/
onChange( callback ) {
onChange( callback ) {
/**
/**
* Used to access the function bound to change events. Don't modify this value directly.
* Used to access the function bound to `onChange` events. Don't modify this value directly.
* Use the `controller.onChange( callback )` method instead.
* Use the `controller.onChange( callback )` method instead.
* @type {Function}
* @type {Function}
*/
*/
this._onChange = callback;
this._onChange = callback;
return this;
return this;
}
}


/**
* Calls the onChange methods of this controller and its parent GUI.
* @protected
*/
_callOnChange() {
_callOnChange() {

this.parent._callOnChange( this );
this.parent._callOnChange( this );

if ( this._onChange !== undefined ) {
if ( this._onChange !== undefined ) {
this._onChange.call( this, this.getValue() );
this._onChange.call( this, this.getValue() );
}
}

this._changed = true;

}
}


// Provided for compatability
/**
* Pass a function to be called after this controller has been modified and loses focus.
* @param {Function} callback
* @returns {this}
* @example
* const controller = gui.add( object, 'property' );
*
* controller.onFinishChange( function( v ) {
* console.log( 'Changes complete: ' + v );
* console.assert( this === controller );
* } );
*/
onFinishChange( callback ) {
onFinishChange( callback ) {
return this.onChange( callback );
/**
* Used to access the function bound to `onFinishChange` events. Don't modify this value
* directly. Use the `controller.onFinishChange( callback )` method instead.
* @type {Function}
*/
this._onFinishChange = callback;
Text moved from lines 1025-1027
return this;
}

/**
* Should be called by Controller when its widgets lose focus.
* @protected
*/
_callOnFinishChange() {

if ( this._changed ) {

this.parent._callOnFinishChange( this );

if ( this._onFinishChange !== undefined ) {
this._onFinishChange.call( this, this.getValue() );
}

}

this._changed = false;

}
}


/**
/**
* Sets the controller back to its initial value.
* Sets the controller back to its initial value.
* @returns {this}
* @returns {this}
*/
*/
reset() {
reset() {
this.setValue( this.initialValue );
this.setValue( this.initialValue );
this._callOnFinishChange();
return this;
return this;
}
}


/**
/**
* Enables this controller.
* Enables this controller.
* @param {boolean} enabled
* @param {boolean} enabled
* @returns {this}
* @returns {this}
* @example
* @example
* controller.enable();
* controller.enable();
* controller.enable( false ); // disable
* controller.enable( false ); // disable
* controller.enable( controller._disabled ); // toggle
* controller.enable( controller._disabled ); // toggle
*/
*/
enable( enabled = true ) {
enable( enabled = true ) {
return this.disable( !enabled );
return this.disable( !enabled );
}
}


/**
/**
* Disables this controller.
* Disables this controller.
* @param {boolean} disabled
* @param {boolean} disabled
* @returns {this}
* @returns {this}
* @example
* @example
* controller.disable();
* controller.disable();
* controller.disable( false ); // enable
* controller.disable( false ); // enable
* controller.disable( !controller._disabled ); // toggle
* controller.disable( !controller._disabled ); // toggle
*/
*/
disable( disabled = true ) {
disable( disabled = true ) {


if ( disabled === this._disabled ) return this;
if ( disabled === this._disabled ) return this;


this._disabled = disabled;
this._disabled = disabled;


this.domElement.classList.toggle( 'disabled', disabled );
this.domElement.classList.toggle( 'disabled', disabled );

this.$disable.toggleAttribute( 'disabled', disabled );
if ( disabled ) {
this.$disable.setAttribute( 'disabled', 'disabled' );
} else {
this.$disable.removeAttribute( 'disabled' );
}


return this;
return this;


}
}


/**
/**
* Destroys this controller and replaces it with a new option controller. Provided as a more
* Destroys this controller and replaces it with a new option controller. Provided as a more
* descriptive syntax for `gui.add`, but primarily for compatibility with dat.gui.
* descriptive syntax for `gui.add`, but primarily for compatibility with dat.gui.
*
*
* Use caution, as this method will destroy old references to this controller. It will also
* Use caution, as this method will destroy old references to this controller. It will also
* change controller order if called out of sequence, moving the option controller to the end of
* change controller order if called out of sequence, moving the option controller to the end of
* the GUI.
* the GUI.
* @example
* @example
* // safe usage
* // safe usage
*
*
* gui.add( object1, 'property' ).options( [ 'a', 'b', 'c' ] );
* gui.add( object1, 'property' ).options( [ 'a', 'b', 'c' ] );
* gui.add( object2, 'property' );
* gui.add( object2, 'property' );
*
*
* // danger
* // danger
*
*
* const c = gui.add( object1, 'property' );
* const c = gui.add( object1, 'property' );
* gui.add( object2, 'property' );
* gui.add( object2, 'property' );
*
*
* c.options( [ 'a', 'b', 'c' ] );
* c.options( [ 'a', 'b', 'c' ] );
* // controller is now at the end of the GUI even though it was added first
* // controller is now at the end of the GUI even though it was added first
*
*
* assert( c.parent.children.indexOf( c ) === -1 )
* assert( c.parent.children.indexOf( c ) === -1 )
* // c references a controller that no longer exists
* // c references a controller that no longer exists
*
*
* @param {object|Array} options
* @param {object|Array} options
* @returns {Controller}
* @returns {Controller}
*/
*/
options( options ) {
options( options ) {
const controller = this.parent.add( this.object, this.property, options );
const controller = this.parent.add( this.object, this.property, options );
controller.name( this._name );
controller.name( this._name );
this.destroy();
this.destroy();
return controller;
return controller;
}
}


/**
/**
* Sets the minimum value. Only works on number controllers.
* Sets the minimum value. Only works on number controllers.
* @param {number} min
* @param {number} min
* @returns {this}
* @returns {this}
*/
*/
min( min ) {
min( min ) {
return this;
return this;
}
}


/**
/**
* Sets the maximum value. Only works on number controllers.
* Sets the maximum value. Only works on number controllers.
* @param {number} max
* @param {number} max
* @returns {this}
* @returns {this}
*/
*/
max( max ) {
max( max ) {
return this;
return this;
}
}


/**
/**
* Sets the step. Only works on number controllers.
* Sets the step. Only works on number controllers.
* @param {number} step
* @param {number} step
* @returns {this}
* @returns {this}
*/
*/
step( step ) {
step( step ) {
return this;
return this;
}
}


/**
/**
* Calls `updateDisplay()` every animation frame. Pass `false` to stop listening.
* Calls `updateDisplay()` every animation frame. Pass `false` to stop listening.
* @param {boolean} listen
* @param {boolean} listen
* @returns {this}
* @returns {this}
*/
*/
listen( listen = true ) {
listen( listen = true ) {


/**
/**
* Used to determine if the controller is currently listening. Don't modify this value
* Used to determine if the controller is currently listening. Don't modify this value
* directly. Use the `controller.listen( true|false )` method instead.
* directly. Use the `controller.listen( true|false )` method instead.
* @type {boolean}
* @type {boolean}
*/
*/
this._listening = listen;
this._listening = listen;


if ( this._listenCallbackID !== undefined ) {
if ( this._listenCallbackID !== undefined ) {
cancelAnimationFrame( this._listenCallbackID );
cancelAnimationFrame( this._listenCallbackID );
this._listenCallbackID = undefined;
this._listenCallbackID = undefined;
}
}


if ( this._listening ) {
if ( this._listening ) {
this._listenCallback();
this._listenCallback();
}
}


return this;
return this;


}
}


_listenCallback() {
_listenCallback() {

this._listenCallbackID = requestAnimationFrame( this._listenCallback );
this._listenCallbackID = requestAnimationFrame( this._listenCallback );

this.updateDisplay();
const value = this.getValue();

// Only update the DOM if the value has changed. Controllers that control non-primitive data
// types get updated every frame to avoid the complexity of comparing objects
if ( value !== this._listenValuePrev || Object( value ) === value ) {
this.updateDisplay();
}

this._listenValuePrev = value;

}
}


/**
/**
* Returns `object[ property ]`.
* Returns `object[ property ]`.
* @returns {any}
* @returns {any}
*/
*/
getValue() {
getValue() {
return this.object[ this.property ];
return this.object[ this.property ];
}
}


/**
/**
* Sets the value of `object[ property ]`, invokes any `onChange` handlers and updates the display.
* Sets the value of `object[ property ]`, invokes any `onChange` handlers and updates the display.
* @param {any} value
* @param {any} value
* @returns {this}
* @returns {this}
*/
*/
setValue( value ) {
setValue( value ) {
this.object[ this.property ] = value;
this.object[ this.property ] = value;
this._callOnChange();
this._callOnChange();
this.updateDisplay();
this.updateDisplay();
return this;
return this;
}
}


/**
/**
* Updates the display to keep it in sync with the current value. Useful for updating your
* Updates the display to keep it in sync with the current value. Useful for updating your
* controllers when their values have been modified outside of the GUI.
* controllers when their values have been modified outside of the GUI.
* @returns {this}
* @returns {this}
*/
*/
updateDisplay() {
updateDisplay() {
return this;
return this;
}
}


load( value ) {
load( value ) {
this.setValue( value );
this.setValue( value );
this._callOnFinishChange();
return this;
}
}


save() {
save() {
return this.getValue();
return this.getValue();
}
}


/**
/**
* Destroys this controller and removes it from the parent GUI.
* Destroys this controller and removes it from the parent GUI.
*/
*/
destroy() {
destroy() {
this.parent.children.splice( this.parent.children.indexOf( this ), 1 );
this.parent.children.splice( this.parent.children.indexOf( this ), 1 );
this.parent.controllers.splice( this.parent.controllers.indexOf( this ), 1 );
this.parent.controllers.splice( this.parent.controllers.indexOf( this ), 1 );
this.parent.$children.removeChild( this.domElement );
this.parent.$children.removeChild( this.domElement );
}
}


}
}


class BooleanController extends Controller {
class BooleanController extends Controller {


constructor( parent, object, property ) {
constructor( parent, object, property ) {


super( parent, object, property, 'boolean', 'label' );
super( parent, object, property, 'boolean', 'label' );


this.$input = document.createElement( 'input' );
this.$input = document.createElement( 'input' );
this.$input.setAttribute( 'type', 'checkbox' );
this.$input.setAttribute( 'type', 'checkbox' );
this.$input.setAttribute( 'aria-labelledby', this.$name.id );


this.$widget.appendChild( this.$input );
this.$widget.appendChild( this.$input );


this.$input.addEventListener( 'change', () => {
this.$input.addEventListener( 'change', () => {
this.setValue( this.$input.checked );
this.setValue( this.$input.checked );
this._callOnFinishChange();
} );
} );


this.$disable = this.$input;
this.$disable = this.$input;


this.updateDisplay();
this.updateDisplay();


}
}


updateDisplay() {
updateDisplay() {
this.$input.checked = this.getValue();
this.$input.checked = this.getValue();
return this;
return this;
}
}


}
}


function normalizeColorString( string ) {
function normalizeColorString( string ) {


let match, result;
let match, result;


if ( match = string.match( /(#|0x)?([a-f0-9]{6})/i ) ) {
if ( match = string.match( /(#|0x)?([a-f0-9]{6})/i ) ) {


result = match[ 2 ];
result = match[ 2 ];


} else if ( match = string.match( /rgb\(\s*(\d*)\s*,\s*(\d*)\s*,\s*(\d*)\s*\)/ ) ) {
} else if ( match = string.match( /rgb\(\s*(\d*)\s*,\s*(\d*)\s*,\s*(\d*)\s*\)/ ) ) {


result = parseInt( match[ 1 ] ).toString( 16 ).padStart( 2, 0 )
result = parseInt( match[ 1 ] ).toString( 16 ).padStart( 2, 0 )
+ parseInt( match[ 2 ] ).toString( 16 ).padStart( 2, 0 )
+ parseInt( match[ 2 ] ).toString( 16 ).padStart( 2, 0 )
+ parseInt( match[ 3 ] ).toString( 16 ).padStart( 2, 0 );
+ parseInt( match[ 3 ] ).toString( 16 ).padStart( 2, 0 );


} else if ( match = string.match( /^#?([a-f0-9])([a-f0-9])([a-f0-9])$/i ) ) {
} else if ( match = string.match( /^#?([a-f0-9])([a-f0-9])([a-f0-9])$/i ) ) {


result = match[ 1 ] + match[ 1 ] + match[ 2 ] + match[ 2 ] + match[ 3 ] + match[ 3 ];
result = match[ 1 ] + match[ 1 ] + match[ 2 ] + match[ 2 ] + match[ 3 ] + match[ 3 ];


}
}


if ( result ) {
if ( result ) {
return '#' + result;
return '#' + result;
}
}


return false;
return false;


}
}


const STRING = {
const STRING = {
isPrimitive: true,
isPrimitive: true,
match: v => typeof v === 'string',
match: v => typeof v === 'string',
fromHexString: normalizeColorString,
fromHexString: normalizeColorString,
toHexString: normalizeColorString
toHexString: normalizeColorString
};
};


const INT = {
const INT = {
isPrimitive: true,
isPrimitive: true,
match: v => typeof v === 'number',
match: v => typeof v === 'number',
fromHexString: string => parseInt( string.substring( 1 ), 16 ),
fromHexString: string => parseInt( string.substring( 1 ), 16 ),
toHexString: value => '#' + value.toString( 16 ).padStart( 6, 0 )
toHexString: value => '#' + value.toString( 16 ).padStart( 6, 0 )
};
};


const ARRAY = {
const ARRAY = {
isPrimitive: false,
isPrimitive: false,
match: Array.isArray,
match: Array.isArray,
fromHexString( string, target, rgbScale = 1 ) {
fromHexString( string, target, rgbScale = 1 ) {


const int = INT.fromHexString( string );
const int = INT.fromHexString( string );


target[ 0 ] = ( int >> 16 & 255 ) / 255 * rgbScale;
target[ 0 ] = ( int >> 16 & 255 ) / 255 * rgbScale;
target[ 1 ] = ( int >> 8 & 255 ) / 255 * rgbScale;
target[ 1 ] = ( int >> 8 & 255 ) / 255 * rgbScale;
target[ 2 ] = ( int & 255 ) / 255 * rgbScale;
target[ 2 ] = ( int & 255 ) / 255 * rgbScale;


},
},
toHexString( [ r, g, b ], rgbScale = 1 ) {
toHexString( [ r, g, b ], rgbScale = 1 ) {


rgbScale = 255 / rgbScale;
rgbScale = 255 / rgbScale;


const int = ( r * rgbScale ) << 16 ^
const int = ( r * rgbScale ) << 16 ^
( g * rgbScale ) << 8 ^
( g * rgbScale ) << 8 ^
( b * rgbScale ) << 0;
( b * rgbScale ) << 0;


return INT.toHexString( int );
return INT.toHexString( int );


}
}
};
};


const OBJECT = {
const OBJECT = {
isPrimitive: false,
isPrimitive: false,
match: v => Object( v ) === v,
match: v => Object( v ) === v,
fromHexString( string, target, rgbScale = 1 ) {
fromHexString( string, target, rgbScale = 1 ) {


const int = INT.fromHexString( string );
const int = INT.fromHexString( string );


target.r = ( int >> 16 & 255 ) / 255 * rgbScale;
target.r = ( int >> 16 & 255 ) / 255 * rgbScale;
target.g = ( int >> 8 & 255 ) / 255 * rgbScale;
target.g = ( int >> 8 & 255 ) / 255 * rgbScale;
target.b = ( int & 255 ) / 255 * rgbScale;
target.b = ( int & 255 ) / 255 * rgbScale;


},
},
toHexString( { r, g, b }, rgbScale = 1 ) {
toHexString( { r, g, b }, rgbScale = 1 ) {


rgbScale = 255 / rgbScale;
rgbScale = 255 / rgbScale;


const int = ( r * rgbScale ) << 16 ^
const int = ( r * rgbScale ) << 16 ^
( g * rgbScale ) << 8 ^
( g * rgbScale ) << 8 ^
( b * rgbScale ) << 0;
( b * rgbScale ) << 0;


return INT.toHexString( int );
return INT.toHexString( int );


}
}
};
};


const FORMATS = [ STRING, INT, ARRAY, OBJECT ];
const FORMATS = [ STRING, INT, ARRAY, OBJECT ];


function getColorFormat( value ) {
function getColorFormat( value ) {
return FORMATS.find( format => format.match( value ) );
return FORMATS.find( format => format.match( value ) );
}
}


class ColorController extends Controller {
class ColorController extends Controller {


constructor( parent, object, property, rgbScale ) {
constructor( parent, object, property, rgbScale ) {


super( parent, object, property, 'color' );
super( parent, object, property, 'color' );


this.$input = document.createElement( 'input' );
this.$input = document.createElement( 'input' );
this.$input.setAttribute( 'type', 'color' );
this.$input.setAttribute( 'type', 'color' );
this.$input.setAttribute( 'tabindex', -1 );
this.$input.setAttribute( 'tabindex', -1 );
this.$input.setAttribute( 'aria-labelledby', this.$name.id );
this.$input.setAttribute( 'aria-labelledby', this.$name.id );


this.$text = document.createElement( 'input' );
this.$text = document.createElement( 'input' );
this.$text.setAttribute( 'type', 'text' );
this.$text.setAttribute( 'type', 'text' );
this.$text.setAttribute( 'spellcheck', 'false' );
this.$text.setAttribute( 'spellcheck', 'false' );
this.$text.setAttribute( 'aria-labelledby', this.$name.id );
this.$text.setAttribute( 'aria-labelledby', this.$name.id );


this.$display = document.createElement( 'div' );
this.$display = document.createElement( 'div' );
this.$display.classList.add( 'display' );
this.$display.classList.add( 'display' );


this.$display.appendChild( this.$input );
this.$display.appendChild( this.$input );
this.$widget.appendChild( this.$display );
this.$widget.appendChild( this.$display );
this.$widget.appendChild( this.$text );
this.$widget.appendChild( this.$text );


this._format = getColorFormat( this.initialValue );
this._format = getColorFormat( this.initialValue );
this._rgbScale = rgbScale;
this._rgbScale = rgbScale;


this._initialValueHexString = this.save();
this._initialValueHexString = this.save();
this._textFocused = false;
this._textFocused = false;


const onInputChange = () => {
this.$input.addEventListener( 'input', () => {
this._setValueFromHexString( this.$input.value );
this._setValueFromHexString( this.$input.value );
};

this.$input.addEventListener( 'change', onInputChange );
this.$input.addEventListener( 'input', onInputChange );

this.$input.addEventListener( 'focus', () => {
this.$display.classList.add( 'focus' );
} );
} );


this.$input.addEventListener( 'blur', () => {
this.$input.addEventListener( 'blur', () => {
this.$display.classList.remove( 'focus' );
this._callOnFinishChange();
} );
} );


this.$text.addEventListener( 'input', () => {
this.$text.addEventListener( 'input', () => {
const tryParse = normalizeColorString( this.$text.value );
const tryParse = normalizeColorString( this.$text.value );
if ( tryParse ) {
if ( tryParse ) {
this._setValueFromHexString( tryParse );
this._setValueFromHexString( tryParse );
}
}
} );
} );


this.$text.addEventListener( 'focus', () => {
this.$text.addEventListener( 'focus', () => {
this._textFocused = true;
this._textFocused = true;
this.$text.select();
this.$text.select();
} );
} );


this.$text.addEventListener( 'blur', () => {
this.$text.addEventListener( 'blur', () => {
this._textFocused = false;
this._textFocused = false;
this.updateDisplay();
this.updateDisplay();
this._callOnFinishChange();
} );
} );


this.$disable = this.$text;
this.$disable = this.$text;


this.updateDisplay();
this.updateDisplay();


}
}


reset() {
reset() {
this._setValueFromHexString( this._initialValueHexString );
this._setValueFromHexString( this._initialValueHexString );
return this;
return this;
}
}


_setValueFromHexString( value ) {
_setValueFromHexString( value ) {


if ( this._format.isPrimitive ) {
if ( this._format.isPrimitive ) {


const newValue = this._format.fromHexString( value );
const newValue = this._format.fromHexString( value );
this.setValue( newValue );
this.setValue( newValue );


} else {
} else {


this._format.fromHexString( value, this.getValue(), this._rgbScale );
this._format.fromHexString( value, this.getValue(), this._rgbScale );
this._callOnChange();
this._callOnChange();
this.updateDisplay();
this.updateDisplay();


}
}


}
}


save() {
save() {
return this._format.toHexString( this.getValue(), this._rgbScale );
return this._format.toHexString( this.getValue(), this._rgbScale );
}
}


load( value ) {
load( value ) {
this._setValueFromHexString( value );
this._setValueFromHexString( value );
this._callOnFinishChange();
return this;
}
}


updateDisplay() {
updateDisplay() {
this.$input.value = this._format.toHexString( this.getValue(), this._rgbScale );
this.$input.value = this._format.toHexString( this.getValue(), this._rgbScale );
if ( !this._textFocused ) {
if ( !this._textFocused ) {
this.$text.value = this.$input.value.substring( 1 );
this.$text.value = this.$input.value.substring( 1 );
}
}
this.$display.style.backgroundColor = this.$input.value;
this.$display.style.backgroundColor = this.$input.value;
return this;
return this;
}
}


}
}


class FunctionController extends Controller {
class FunctionController extends Controller {


constructor( parent, object, property ) {
constructor( parent, object, property ) {


super( parent, object, property, 'function' );
super( parent, object, property, 'function' );


// Buttons are the only case where widget contains name
// Buttons are the only case where widget contains name
this.$button = document.createElement( 'button' );
this.$button = document.createElement( 'button' );
this.$button.appendChild( this.$name );
this.$button.appendChild( this.$name );
this.$widget.appendChild( this.$button );
this.$widget.appendChild( this.$button );


this.$button.addEventListener( 'click', e => {
this.$button.addEventListener( 'click', e => {
e.preventDefault();
e.preventDefault();
this.getValue().call( this.object );
this.getValue().call( this.object );
} );
} );


// enables :active pseudo class on mobile
// enables :active pseudo class on mobile
this.$button.addEventListener( 'touchstart', () => { } );
this.$button.addEventListener( 'touchstart', () => { } );


this.$disable = this.$button;
this.$disable = this.$button;


}
}


}
}


class NumberController extends Controller {
class NumberController extends Controller {


constructor( parent, object, property, min, max, step ) {
constructor( parent, object, property, min, max, step ) {


super( parent, object, property, 'number' );
super( parent, object, property, 'number' );


this._initInput();
this._initInput();


this.min( min );
this.min( min );
this.max( max );
this.max( max );


const stepExplicit = step !== undefined;
const stepExplicit = step !== undefined;
this.step( stepExplicit ? step : this._getImplicitStep(), stepExplicit );
this.step( stepExplicit ? step : this._getImplicitStep(), stepExplicit );


this.updateDisplay();
this.updateDisplay();


}
}


min( min ) {
min( min ) {
this._min = min;
this._min = min;
this._onUpdateMinMax();
this._onUpdateMinMax();
return this;
return this;
}
}


max( max ) {
max( max ) {
this._max = max;
this._max = max;
this._onUpdateMinMax();
this._onUpdateMinMax();
return this;
return this;
}
}


step( step, explicit = true ) {
step( step, explicit = true ) {
this._step = step;
this._step = step;
this._stepExplicit = explicit;
this._stepExplicit = explicit;
return this;
return this;
}
}


updateDisplay() {
updateDisplay() {


const value = this.getValue();
const value = this.getValue();


if ( this._hasSlider ) {
if ( this._hasSlider ) {
const percent = ( value - this._min ) / ( this._max - this._min );

this.$fill.style.setProperty( 'width', percent * 100 + '%' );
let percent = ( value - this._min ) / ( this._max - this._min );
percent = Math.max( 0, Math.min( percent, 1 ) );

this.$fill.style.width = percent * 100 + '%';

}
}


if ( !this._inputFocused ) {
if ( !this._inputFocused ) {
this.$input.value = value;
this.$input.value = value;
}
}


return this;
return this;


}
}


_initInput() {
_initInput() {


this.$input = document.createElement( 'input' );
this.$input = document.createElement( 'input' );
this.$input.setAttribute( 'type', 'text' );
this.$input.setAttribute( 'type', 'number' );
this.$input.setAttribute( 'inputmode', 'numeric' );
this.$input.setAttribute( 'step', 'any' );
this.$input.setAttribute( 'aria-labelledby', this.$name.id );
this.$input.setAttribute( 'aria-labelledby', this.$name.id );


this.$widget.appendChild( this.$input );
this.$widget.appendChild( this.$input );


this.$disable = this.$input;
this.$disable = this.$input;


const onInput = () => {
const onInput = () => {


const value = parseFloat( this.$input.value );
const value = parseFloat( this.$input.value );


if ( isNaN( value ) ) return;
if ( isNaN( value ) ) return;


this.setValue( this._clamp( value ) );
this.setValue( this._clamp( value ) );


};
};


// invoked on wheel or arrow key up/down
// Keys & mouse wheel
// ---------------------------------------------------------------------

const increment = delta => {
const increment = delta => {


const value = parseFloat( this.$input.value );
const value = parseFloat( this.$input.value );


if ( isNaN( value ) ) return;
if ( isNaN( value ) ) return;


this._snapClampSetValue( value + delta );
this._snapClampSetValue( value + delta );


// Force the input to updateDisplay when it's focused
// Force the input to updateDisplay when it's focused
this.$input.value = this.getValue();
this.$input.value = this.getValue();


};
};


const onKeyDown = e => {
const onKeyDown = e => {
if ( e.code === 'Enter' ) {
if ( e.code === 'Enter' ) {
this.$input.blur();
this.$input.blur();
}
}
if ( e.code === 'ArrowUp' ) {
if ( e.code === 'ArrowUp' ) {
e.preventDefault();
e.preventDefault();
increment( this._step * this._arrowKeyMultiplier( e ) );
increment( this._step * this._arrowKeyMultiplier( e ) );
}
}
if ( e.code === 'ArrowDown' ) {
if ( e.code === 'ArrowDown' ) {
e.preventDefault();
e.preventDefault();
increment( -1 * this._step * this._arrowKeyMultiplier( e ) );
increment( this._step * this._arrowKeyMultiplier( e ) * -1 );
}
}
};
};


const onWheel = e => {
const onWheel = e => {
if ( this._inputFocused ) {
if ( this._inputFocused ) {
e.preventDefault();
e.preventDefault();
increment( this._normalizeMouseWheel( e ) * this._step );
increment( this._step * this._normalizeMouseWheel( e ) );
}
}
};
};


// Vertical drag
// ---------------------------------------------------------------------

let testingForVerticalDrag = false,
initClientX,
initClientY,
prevClientY,
initValue,
dragDelta;

// Once the mouse is dragged more than DRAG_THRESH px on any axis, we decide
// on the user's intent: horizontal means highlight, vertical means drag.
const DRAG_THRESH = 5;

const onMouseDown = e => {

initClientX = e.clientX;
initClientY = prevClientY = e.clientY;
testingForVerticalDrag = true;

initValue = this.getValue();
dragDelta = 0;

window.addEventListener( 'mousemove', onMouseMove );
window.addEventListener( 'mouseup', onMouseUp );

};

const onMouseMove = e => {

if ( testingForVerticalDrag ) {

const dx = e.clientX - initClientX;
const dy = e.clientY - initClientY;

if ( Math.abs( dy ) > DRAG_THRESH ) {

e.preventDefault();
this.$input.blur();
testingForVerticalDrag = false;
this._setDraggingStyle( true, 'vertical' );

} else if ( Math.abs( dx ) > DRAG_THRESH ) {

onMouseUp();

}

}

// This isn't an else so that the first move counts towards dragDelta
if ( !testingForVerticalDrag ) {

const dy = e.clientY - prevClientY;

dragDelta -= dy * this._step * this._arrowKeyMultiplier( e );

// Clamp dragDelta so we don't have 'dead space' after dragging past bounds.
// We're okay with the fact that bounds can be undefined here.
if ( initValue + dragDelta > this._max ) {
dragDelta = this._max - initValue;
} else if ( initValue + dragDelta < this._min ) {
dragDelta = this._min - initValue;
}

this._snapClampSetValue( initValue + dragDelta );

}

prevClientY = e.clientY;

};

const onMouseUp = () => {
this._setDraggingStyle( false, 'vertical' );
this._callOnFinishChange();
window.removeEventListener( 'mousemove', onMouseMove );
window.removeEventListener( 'mouseup', onMouseUp );
};

// Focus state & onFinishChange
// ---------------------------------------------------------------------

const onFocus = () => {
const onFocus = () => {
this._inputFocused = true;
this._inputFocused = true;
};
};


const onBlur = () => {
const onBlur = () => {
this._inputFocused = false;
this._inputFocused = false;
this.updateDisplay();
this.updateDisplay();
this._callOnFinishChange();
};
};


this.$input.addEventListener( 'input', onInput );
this.$input.addEventListener( 'keydown', onKeyDown );
this.$input.addEventListener( 'wheel', onWheel );
this.$input.addEventListener( 'mousedown', onMouseDown );
this.$input.addEventListener( 'focus', onFocus );
this.$input.addEventListener( 'focus', onFocus );
this.$input.addEventListener( 'input', onInput );
this.$input.addEventListener( 'blur', onBlur );
this.$input.addEventListener( 'blur', onBlur );
this.$input.addEventListener( 'keydown', onKeyDown );
this.$input.addEventListener( 'wheel', onWheel, { passive: false } );


}
}


_initSlider() {
_initSlider() {


this._hasSlider = true;
this._hasSlider = true;


// Build DOM
// Build DOM
// ---------------------------------------------------------------------
// ---------------------------------------------------------------------


this.$slider = document.createElement( 'div' );
this.$slider = document.createElement( 'div' );
this.$slider.classList.add( 'slider' );
this.$slider.classList.add( 'slider' );


this.$fill = document.createElement( 'div' );
this.$fill = document.createElement( 'div' );
this.$fill.classList.add( 'fill' );
this.$fill.classList.add( 'fill' );


this.$slider.appendChild( this.$fill );
this.$slider.appendChild( this.$fill );
this.$widget.insertBefore( this.$slider, this.$input );
this.$widget.insertBefore( this.$slider, this.$input );


this.domElement.classList.add( 'hasSlider' );
this.domElement.classList.add( 'hasSlider' );


// Map clientX to value
// Map clientX to value
// ---------------------------------------------------------------------
// ---------------------------------------------------------------------


const map = ( v, a, b, c, d ) => {
const map = ( v, a, b, c, d ) => {
return ( v - a ) / ( b - a ) * ( d - c ) + c;
return ( v - a ) / ( b - a ) * ( d - c ) + c;
};
};


const setValueFromX = clientX => {
const setValueFromX = clientX => {
const rect = this.$slider.getBoundingClientRect();
const rect = this.$slider.getBoundingClientRect();
let value = map( clientX, rect.left, rect.right, this._min, this._max );
let value = map( clientX, rect.left, rect.right, this._min, this._max );
this._snapClampSetValue( value );
this._snapClampSetValue( value );
};
};


// Bind mouse listeners
// Mouse drag
// ---------------------------------------------------------------------
// ---------------------------------------------------------------------


const mouseDown = e => {
const mouseDown = e => {
this._setDraggingStyle( true );
setValueFromX( e.clientX );
setValueFromX( e.clientX );
this._setActiveStyle( true );
window.addEventListener( 'mousemove', mouseMove );
window.addEventListener( 'mousemove', mouseMove );
window.addEventListener( 'mouseup', mouseUp );
window.addEventListener( 'mouseup', mouseUp );
};
};


const mouseMove = e => {
const mouseMove = e => {
setValueFromX( e.clientX );
setValueFromX( e.clientX );
};
};


const mouseUp = () => {
const mouseUp = () => {
this._setActiveStyle( false );
this._callOnFinishChange();
this._setDraggingStyle( false );
window.removeEventListener( 'mousemove', mouseMove );
window.removeEventListener( 'mousemove', mouseMove );
window.removeEventListener( 'mouseup', mouseUp );
window.removeEventListener( 'mouseup', mouseUp );
};
};


this.$slider.addEventListener( 'mousedown', mouseDown );
// Touch drag

// Bind touch listeners
// ---------------------------------------------------------------------
// ---------------------------------------------------------------------


let testingForScroll = false, prevClientX, prevClientY;
let testingForScroll = false, prevClientX, prevClientY;


const beginTouchDrag = e => {
e.preventDefault();
this._setDraggingStyle( true );
setValueFromX( e.touches[ 0 ].clientX );
testingForScroll = false;
};

const onTouchStart = e => {
const onTouchStart = e => {


if ( e.touches.length > 1 ) return;
if ( e.touches.length > 1 ) return;


// If we're in a scrollable container, we should wait for the first
// If we're in a scrollable container, we should wait for the first
// touchmove to see if the user is trying to slide or scroll.
// touchmove to see if the user is trying to slide or scroll.
if ( this._hasScrollBar ) {
if ( this._hasScrollBar ) {


prevClientX = e.touches[ 0 ].clientX;
prevClientX = e.touches[ 0 ].clientX;
prevClientY = e.touches[ 0 ].clientY;
prevClientY = e.touches[ 0 ].clientY;
testingForScroll = true;
testingForScroll = true;


} else {
} else {


// Otherwise, we can set the value straight away on touchstart.
// Otherwise, we can set the value straight away on touchstart.
e.preventDefault();
beginTouchDrag( e );
setValueFromX( e.touches[ 0 ].clientX );
this._setActiveStyle( true );
testingForScroll = false;


}
}


window.addEventListener( 'touchmove', onTouchMove, { passive: false } );
window.addEventListener( 'touchmove', onTouchMove );
window.addEventListener( 'touchend', onTouchEnd );
window.addEventListener( 'touchend', onTouchEnd );


};
};


const onTouchMove = e => {
const onTouchMove = e => {


if ( testingForScroll ) {
if ( testingForScroll ) {


const dx = e.touches[ 0 ].clientX - prevClientX;
const dx = e.touches[ 0 ].clientX - prevClientX;
const dy = e.touches[ 0 ].clientY - prevClientY;
const dy = e.touches[ 0 ].clientY - prevClientY;


if ( Math.abs( dx ) > Math.abs( dy ) ) {
if ( Math.abs( dx ) > Math.abs( dy ) ) {


// We moved horizontally, set the value and stop checking.
// We moved horizontally, set the value and stop checking.
e.preventDefault();
beginTouchDrag( e );
setValueFromX( e.touches[ 0 ].clientX );
this._setActiveStyle( true );
testingForScroll = false;


} else {
} else {


// This was, in fact, an attempt to scroll. Abort.
// This was, in fact, an attempt to scroll. Abort.
window.removeEventListener( 'touchmove', onTouchMove );
window.removeEventListener( 'touchmove', onTouchMove );
window.removeEventListener( 'touchend', onTouchEnd );
window.removeEventListener( 'touchend', onTouchEnd );


}
}


} else {
} else {


e.preventDefault();
e.preventDefault();
setValueFromX( e.touches[ 0 ].clientX );
setValueFromX( e.touches[ 0 ].clientX );


}
}


};
};


const onTouchEnd = () => {
const onTouchEnd = () => {
this._setActiveStyle( false );
this._callOnFinishChange();
this._setDraggingStyle( false );
window.removeEventListener( 'touchmove', onTouchMove );
window.removeEventListener( 'touchmove', onTouchMove );
window.removeEventListener( 'touchend', onTouchEnd );
window.removeEventListener( 'touchend', onTouchEnd );
};
};


this.$slider.addEventListener( 'touchstart', onTouchStart );
// Mouse wheel

// Bind wheel listeners
// ---------------------------------------------------------------------
// ---------------------------------------------------------------------


// We have to use a debounced function to call onFinishChange because
// there's no way to tell when the user is "done" mouse-wheeling.
const callOnFinishChange = this._callOnFinishChange.bind( this );
const WHEEL_DEBOUNCE_TIME = 400;
let wheelFinishChangeTimeout;

const onWheel = e => {
const onWheel = e => {


// ignore vertical wheels if there's a scrollbar
// ignore vertical wheels if there's a scrollbar
const isVertical = Math.abs( e.deltaX ) < Math.abs( e.deltaY );
const isVertical = Math.abs( e.deltaX ) < Math.abs( e.deltaY );
if ( isVertical && this._hasScrollBar ) return;
if ( isVertical && this._hasScrollBar ) return;


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


// set value
const delta = this._normalizeMouseWheel( e ) * this._step;
const delta = this._normalizeMouseWheel( e ) * this._step;
this._snapClampSetValue( this.getValue() + delta );
this._snapClampSetValue( this.getValue() + delta );


// force the input to updateDisplay when it's focused
this.$input.value = this.getValue();

// debounce onFinishChange
clearTimeout( wheelFinishChangeTimeout );
wheelFinishChangeTimeout = setTimeout( callOnFinishChange, WHEEL_DEBOUNCE_TIME );

};
};


this.$slider.addEventListener( 'wheel', onWheel, { passive: false } );
this.$slider.addEventListener( 'mousedown', mouseDown );
this.$slider.addEventListener( 'touchstart', onTouchStart );
this.$slider.addEventListener( 'wheel', onWheel );


}
}


_setActiveStyle( active ) {
_setDraggingStyle( active, axis = 'horizontal' ) {
this.$slider.classList.toggle( 'active', active );
if ( this.$slider ) {
document.body.classList.toggle( 'lil-gui-slider-active', active );
this.$slider.classList.toggle( 'active', active );
}
document.body.classList.toggle( 'lil-gui-dragging', active );
document.body.classList.toggle( `lil-gui-${axis}`, active );
}
}


_getImplicitStep() {
_getImplicitStep() {


if ( this._hasMin && this._hasMax ) {
if ( this._hasMin && this._hasMax ) {
return ( this._max - this._min ) / 1000;
return ( this._max - this._min ) / 1000;
}
}


return 0.1;
return 0.1;


}
}


_onUpdateMinMax() {
_onUpdateMinMax() {


if ( !this._hasSlider && this._hasMin && this._hasMax ) {
if ( !this._hasSlider && this._hasMin && this._hasMax ) {


// If this is the first time we're hearing about min and max
// If this is the first time we're hearing about min and max
// and we haven't explicitly stated what our step is, let's
// and we haven't explicitly stated what our step is, let's
// update that too.
// update that too.
if ( !this._stepExplicit ) {
if ( !this._stepExplicit ) {
this.step( this._getImplicitStep(), false );
this.step( this._getImplicitStep(), false );
}
}


this._initSlider();
this._initSlider();
this.updateDisplay();
this.updateDisplay();


}
}


}
}


_normalizeMouseWheel( e ) {
_normalizeMouseWheel( e ) {


let { deltaX, deltaY } = e;
let { deltaX, deltaY } = e;


// 2019: Safari and Chrome report weird non-integral values for an actual
// Safari and Chrome report weird non-integral values for a notched wheel,
// mouse with a wheel connected to my 2015 macbook, but still expose actual
// but still expose actual lines scrolled via wheelDelta. Notched wheels
// lines scrolled via wheelDelta.
// should behave the same way as arrow keys.
if ( Math.floor( e.deltaY ) !== e.deltaY && e.wheelDelta ) {
if ( Math.floor( e.deltaY ) !== e.deltaY && e.whe
deltaX = 0;
deltaY = -e.wheelDelta / 120;
}

const wheel = deltaX + -deltaY;

return wheel;

}

_arrowKeyMultiplier( e ) {

if ( this._stepExplicit ) {
return e.shiftKey ? 10 : 1;
} else if ( e.shiftKey ) {
return 100;
} else if ( e.altKey ) {
return 1;
}
return 10;

}

_snap( value ) {

// This would be the logical way to do things, but floating point errors.
// return Math.round( value / this._step ) * this._step;

// Using inverse step solves a lot of them, but not all
// const inverseStep = 1 / this._step;
// return Math.round( value * inverseStep ) / inverseStep;

// Not happy about this, but haven't seen it break.
const r = Math.round( value / this._step ) * this._step;
return parseFloat( r.toPrecision( 15 ) );

}

_clamp( value ) {
const min = this._hasMin ? this._min : -Infinity;
const max = this._hasMax ? this._max : Infinity;
return Math.max( min, Math.min( max, value ) );
}

_snapClampSetValue( value ) {
this.setValue( this._clamp( this._snap( value ) ) );
}

get _hasScrollBar() {
const root = this.parent.root.$children;
return root.scrollHeight > root.clientHeight;
}

get _hasMin() {
return this._min !== undefined;
}

get _hasMax() {
return this._max !== undefined;
}

}

class OptionController extends Controller {

constructor( parent, object, property, options ) {

super( parent, object, property, 'option' );

this.$select = document.createElement( 'select' );
this.$select.setAttribute( 'aria-labelledby', this.$name.id );

this.$display = document.createElement( 'div' );
this.$display.classList.add( 'display' );

this._values = Array.isArray( options ) ? options : Object.values( options );
this._names = Array.isArray( options ) ? options : Object.keys( options );

this._names.forEach( name => {
const $option = document.createElement( 'option' );
$option.innerHTML = name;
this.$select.appendChild( $option );
} );

this.$select.addEventListener( 'change', () => {
this.setValue( this._values[ this.$select.selectedIndex ] );
} );

this.$select.addEventListener( 'focus', () => {
this.$display.classList.add( 'focus' );
} );

this.$select.addEventListener( 'blur', () => {
this.$display.classList.remove( 'focus' );
} );

this.$widget.appendChild( this.$select );
this.$widget.appendChild( this.$display );

this.$disable = this.$select;

this.updateDisplay();

}

updateDisplay() {
const value = this.getValue();
const index = this._values.indexOf( value );
this.$select.selectedIndex = index;
this.$display.innerHTML = index === -1 ? value : this._names[ index ];
Text moved to lines 166-168
return this;
}

}

class StringController extends Controller {

constructor( parent, object, property ) {

super( parent, object, property, 'string' );

this.$input = document.createElement( 'input' );
this.$input.setAttribute( 'type', 'text' );
this.$input.setAttribute( 'aria-labelledby', this.$name.id );

this.$input.addEventListener( 'input', () => {
this.setValue( this.$input.value );
} );

this.$input.addEventListener( 'keydown', e => {
if ( e.code === 'Enter' ) {
this.$input.blur();
}
} );

this.$widget.appendChild( this.$input );

this.$disable = this.$input;

this.updateDisplay();

}

updateDisplay() {
this.$input.value = this.getValue();
return this;
}

}

const stylesheet = `.lil-gui {
font-family: var(--font-family);
font-size: var(--font-size);
line-height: 1;
font-we