// This is a vanillajs implementation of Houdini's number input widgets. // It basically popup a visual sensitivity slider of steps to use as incr/decr // TODO: Convert it to IWidget // import styles from "./style.module.css"; function getValidNumber(numberInput) { let num = isNaN(numberInput.value) || numberInput.value === '' ? 0 : parseFloat(numberInput.value) return num } /** * Number input widgets */ export class NumberInputWidget { constructor(containerId, numberOfInputs = 1, isDebug = false) { this.container = document.getElementById(containerId) this.numberOfInputs = numberOfInputs this.currentInput = null // Store the currently active input this.threshold = 30 this.mouseSensitivityMultiplier = 0.05 this.debug = isDebug //- states this.initialMouseX this.lastMouseX this.activeStep = 1 this.accumulatedDelta = 0 this.stepLocked = false this.thresholdExceeded = false this.isDragging = false const styleTagId = 'mtb-constant-style' let styleTag = document.head.querySelector(`#${styleTagId}`) if (!styleTag) { styleTag = document.createElement('style') styleTag.type = 'text/css' styleTag.id = styleTagId styleTag.innerHTML = ` .${containerId}{ margin-top: 20px; margin-bottom: 20px; } .sensitivity-menu { display: none; position: absolute; /* Additional styling */ } .sensitivity-menu .step { cursor: pointer; padding: 0.5em; /* Add more styling as needed */ } .sensitivity-menu { font-family: monospace; background: var(--bg-color); border: 1px solid var(--fg-color); /* Highlight for the active step */ } .number-input { background: var(--bg-color); color: var(--fg-color) } .sensitivity-menu .step.active { background-color:var(--drag-text); /* Highlight for the active step */ } .sensitivity-menu .step.locked { background-color: #f00; /* Change to your preferred color for the locked state */ } #debug-container { transform: translateX(50%); width: 50%; text-align: center; font-family: monospace; } ` document.head.appendChild(styleTag) } this.createWidgetElements() this.initializeEventListeners() } setLabel(str) { this.label.textContent = str } setValue(...values) { if (values.length !== this.numberInputs.length) { console.error('Number of values does not match the number of inputs.') console.error( `You provided ${values.length} but the input want ${this.numberInputs.length}`, { values }, ) return } // Set each input value this.numberInputs.forEach((input, index) => { input.value = values[index] }) } getValue() { const value = [] this.numberInputs.forEach((input, index) => { value.push(Number.parseFloat(input.value) || 0.0) }) return value } resetValues() { for (const input of numberInputs) { input.value = 0 } this.onChange?.(this.getValue()) } createWidgetElements() { this.label = document.createElement('label') this.label.textContent = 'Control All:' this.label.className = 'widget-label' this.container.appendChild(this.label) this.label.addEventListener('mousedown', (event) => { if (event.button === 1) { this.currentInput = null this.handleMouseDown(event) } }) this.label.addEventListener('contextmenu', (event) => { event.preventDefault() this.resetValues() }) this.numberInputs = [] // create linked inputs for (let i = 0; i < this.numberOfInputs; i++) { const numberInput = document.createElement('input') numberInput.type = 'number' numberInput.className = 'number-input' //styles.numberInput; //"number-input"; numberInput.step = 'any' this.container.appendChild(numberInput) this.numberInputs.push(numberInput) numberInput.addEventListener('mousedown', (event) => { if (event.button === 1) { this.currentInput = numberInput this.handleMouseDown(event) } }) } this.sensitivityMenu = document.createElement('div') this.sensitivityMenu.className = 'sensitivity-menu' //styles.sensitivityMenu; //"sensitivity-menu"; this.container.appendChild(this.sensitivityMenu) // create steps const stepsValues = [0.001, 0.01, 0.1, 1, 10, 100] stepsValues.forEach((value) => { const step = document.createElement('div') step.className = 'step' //styles.step //"step"; step.dataset.step = value step.textContent = value.toString() this.sensitivityMenu.appendChild(step) }) this.steps = this.sensitivityMenu.getElementsByClassName('step') //styles.step) if (this.debug) { this.debugContainer = document.createElement('div') this.debugContainer.id = 'debug-container' //styles.debugContainer //"debugContainer"; document.body.appendChild(this.debugContainer) } } showSensitivityMenu(pageX, pageY) { this.sensitivityMenu.style.display = 'block' this.sensitivityMenu.style.left = `${pageX}px` this.sensitivityMenu.style.top = `${pageY}px` this.initialMouseX = pageX this.lastMouseX = pageX this.isDragging = true this.thresholdExceeded = false this.stepLocked = false this.updateDebugInfo() } updateDebugInfo() { if (this.debug) { this.debugContainer.innerHTML = `
Active Step: ${this.activeStep}
Initial Mouse X: ${this.initialMouseX}
Last Mouse X: ${this.lastMouseX}
Accumulated Delta: ${this.accumulatedDelta}
Threshold Exceeded: ${this.thresholdExceeded}
Step Locked: ${this.stepLocked}
Number Input Value: ${this.currentInput?.value}
` } } handleMouseDown(event) { if (event.button === 1) { this.showSensitivityMenu( event.target.offsetWidth, event.target.offsetHeight, ) event.preventDefault() } } handleMouseUp(event) { if (event.button === 1) { this.resetWidgetState() } } handleClickOutside(event) { if (event.target !== this.numberInput) { this.resetWidgetState() } } handleMouseMove(event) { if (this.sensitivityMenu.style.display === 'block') { const relativeY = event.pageY - 300 // this.sensitivityMenu.offsetTop const horizontalDistanceFromInitial = Math.abs( event.target.offsetWidth - this.initialMouseX, ) // Unlock if the mouse moves back towards the initial position if (horizontalDistanceFromInitial < this.threshold) { this.thresholdExceeded = false this.stepLocked = false this.accumulatedDelta = 0 } // Update step only if it is not locked if (!this.stepLocked) { for (let step of this.steps) { step.classList.remove('active') //styles.active) step.classList.remove('locked') //styles.locked) if ( relativeY >= step.offsetTop && relativeY <= step.offsetTop + step.offsetHeight ) { step.classList.add('active') //styles.active) this.setActiveStep(parseFloat(step.dataset.step)) } } } if (this.stepLocked) { this.sensitivityMenu .querySelector('.step.active') ?.classList.add('locked') } this.updateStepValue(event.pageX) } } initializeEventListeners() { document.addEventListener('mousemove', (event) => this.handleMouseMove(event), ) document.addEventListener('mouseup', (event) => this.handleMouseUp(event)) document.addEventListener('click', (event) => this.handleClickOutside(event), ) } setActiveStep(val) { if (this.activeStep !== val) { this.activeStep = val this.stepLocked = false this.accumulatedDelta = 0 this.thresholdExceeded = false } } resetWidgetState() { this.sensitivityMenu.style.display = 'none' this.isDragging = false this.lastMouseX = undefined this.thresholdExceeded = false this.stepLocked = false this.updateDebugInfo() } updateStepValue(mouseX) { if (this.isDragging && this.lastMouseX !== undefined) { const deltaX = mouseX - this.lastMouseX this.accumulatedDelta += deltaX if ( !this.thresholdExceeded && Math.abs(this.accumulatedDelta) > this.threshold ) { this.thresholdExceeded = true this.stepLocked = true } if (this.thresholdExceeded && this.stepLocked) { // frequency of value changes if ( Math.abs(this.accumulatedDelta) * this.mouseSensitivityMultiplier >= 1 ) { const valueChange = Math.sign(this.accumulatedDelta) * this.activeStep if (this.currentInput) { this.currentInput.value = getValidNumber(this.currentInput) + valueChange this.onChange?.(this.getValue()) } else { this.numberInputs.forEach((input) => { input.value = getValidNumber(input) + valueChange }) } this.accumulatedDelta = 0 } } this.lastMouseX = mouseX } this.updateDebugInfo() } }