package nl.astraeus.vst.ui.components import kotlinx.html.SVG import kotlinx.html.div import kotlinx.html.js.onMouseDownFunction import kotlinx.html.js.onMouseLeaveFunction import kotlinx.html.js.onMouseUpFunction import kotlinx.html.style import kotlinx.html.svg import nl.astraeus.css.properties.AlignItems import nl.astraeus.css.properties.Display import nl.astraeus.css.properties.FlexDirection import nl.astraeus.css.properties.FontWeight import nl.astraeus.css.properties.JustifyContent import nl.astraeus.css.properties.Position import nl.astraeus.css.properties.TextAlign import nl.astraeus.css.properties.prc import nl.astraeus.css.properties.px import nl.astraeus.css.properties.rem import nl.astraeus.css.style.cls import nl.astraeus.komp.HtmlBuilder import nl.astraeus.komp.Komponent import nl.astraeus.vst.ui.css.Css import nl.astraeus.vst.ui.css.Css.defineCss import nl.astraeus.vst.ui.css.CssId import nl.astraeus.vst.ui.css.CssName import nl.astraeus.vst.ui.util.height import nl.astraeus.vst.ui.util.rect import nl.astraeus.vst.ui.util.width import org.w3c.dom.events.MouseEvent /** * The keyboard component shows 1 octabe of a (piano) keyboard and * calls the noteDown and noteUp methods when the keys are clicked */ class KeyboardComponent( val title: String = "Keyboard", initialOctave: Int = 4, val keyboardWidth: Int = 210, val keyboardHeight: Int = keyboardWidth / 2, val rounding: Int = 4, val onNoteDown: (Int) -> Unit = {}, val onNoteUp: (Int) -> Unit = {} ) : Komponent() { // Define a data class for keyboard state data class KeyboardState( val octave: Int, val pressedNotes: Set = emptySet() ) // Use immutable state private var state = KeyboardState(initialOctave) // Current octave with range validation var octave: Int get() = state.octave set(value) { updateOctave(value) } // Update state with functions that return new state private fun updateOctave(newOctave: Int) { state = state.copy(octave = newOctave.coerceIn(MIN_OCTAVE, MAX_OCTAVE)) requestUpdate() } // MIDI note numbers for one octave private val whiteKeys = BASE_WHITE_KEYS private val blackKeys = BASE_BLACK_KEYS // Key dimensions private val whiteKeyWidth = keyboardWidth / WHITE_KEYS_PER_OCTAVE private val blackKeyWidth = (keyboardWidth / BLACK_KEY_WIDTH_DIVISOR) private val blackKeyHeight = keyboardHeight * BLACK_KEY_HEIGHT_PERCENTAGE / 100 // Calculate positions for black keys private val blackKeyPositions = listOf( whiteKeyWidth - blackKeyWidth/2, whiteKeyWidth*2 - blackKeyWidth/2, whiteKeyWidth*4 - blackKeyWidth/2, whiteKeyWidth*5 - blackKeyWidth/2, whiteKeyWidth*6 - blackKeyWidth/2 ) fun noteDown(midiNote: Int) { state = state.copy(pressedNotes = state.pressedNotes + midiNote) onNoteDown(midiNote) requestUpdate() } fun noteUp(midiNote: Int) { state = state.copy(pressedNotes = state.pressedNotes - midiNote) onNoteUp(midiNote) requestUpdate() } private fun releaseAllNotes() { // Create a copy of the set to avoid concurrent modification val notesToRelease = state.pressedNotes.toSet() for (note in notesToRelease) { noteUp(note) } // Just to be safe, clear all pressed notes state = state.copy(pressedNotes = emptySet()) } private fun getOctaveOffset(): Int = (octave - OCTAVE_BASE) * NOTES_PER_OCTAVE private fun getMidiNoteFromMousePosition(x: Double, y: Double): Int? { // Check if click is on a black key (black keys are on top of white keys) if (y <= blackKeyHeight) { for (j in 0 until BLACK_KEYS_PER_OCTAVE) { if (x >= blackKeyPositions[j] && x <= blackKeyPositions[j] + blackKeyWidth) { return blackKeys[j] + getOctaveOffset() } } // If no black key was pressed, check for white key val keyIndex = (x / whiteKeyWidth).toInt() if (keyIndex in 0 until WHITE_KEYS_PER_OCTAVE) { return whiteKeys[keyIndex] + getOctaveOffset() } } else { // If y > blackKeyHeight, it's definitely a white key val keyIndex = (x / whiteKeyWidth).toInt() if (keyIndex in 0 until WHITE_KEYS_PER_OCTAVE) { return whiteKeys[keyIndex] + getOctaveOffset() } } return null } override fun HtmlBuilder.render() { div(KeyboardCls.name) { style = "width: ${keyboardWidth}px; height: ${keyboardHeight + KEYBOARD_CONTROLS_HEIGHT}px" onMouseLeaveFunction = { event -> if (event is MouseEvent) { releaseAllNotes() event.preventDefault() } } div(KeyboardControlsCls.name) { // Decrease octave button div(OctaveButtonCls.name) { style = "width: ${whiteKeyWidth}px" +"<" onMouseDownFunction = { event -> if (event is MouseEvent) { updateOctave(octave - 1) event.preventDefault() } } } // Title and octave display container div(KeyboardInfoCls.name) { div(KeyboardTitleCls.name) { // Show title of the keyboard +title } div(KeyboardOctaveCls.name) { // Show current octave the piano is being played at +"Octave: $octave" } } // Increase octave button div(OctaveButtonCls.name) { style = "width: ${whiteKeyWidth}px" +">" onMouseDownFunction = { event -> if (event is MouseEvent) { updateOctave(octave + 1) event.preventDefault() } } } } div(KeyboardKeysCls.name) { // Draw the keyboard with SVG, and mousedown and mouseup methods // that call noteDown and noteUp svg { width(keyboardWidth) height(keyboardHeight) // Define mouse event handlers at the SVG level onMouseDownFunction = { event -> if (event is MouseEvent) { getMidiNoteFromMousePosition(event.offsetX, event.offsetY)?.let { noteDown(it) } event.preventDefault() } } onMouseUpFunction = { event -> if (event is MouseEvent) { getMidiNoteFromMousePosition(event.offsetX, event.offsetY)?.let { noteUp(it) } event.preventDefault() } } // Draw white keys this.renderWhiteKeys() // Draw black keys this.renderBlackKeys() } } } } // Render white keys private fun SVG.renderWhiteKeys() { for (i in 0 until WHITE_KEYS_PER_OCTAVE) { val midiNote = whiteKeys[i] + getOctaveOffset() val isPressed = state.pressedNotes.contains(midiNote) rect( i * whiteKeyWidth, 0, whiteKeyWidth, keyboardHeight, rounding, if (isPressed) WhiteKeyPressedCls.name else WhiteKeyCls.name ) } } // Render black keys private fun SVG.renderBlackKeys() { for (i in 0 until BLACK_KEYS_PER_OCTAVE) { val midiNote = blackKeys[i] + getOctaveOffset() val isPressed = state.pressedNotes.contains(midiNote) rect( blackKeyPositions[i], 0, blackKeyWidth, blackKeyHeight, rounding, if (isPressed) BlackKeyPressedCls.name else BlackKeyCls.name ) } } companion object : CssId("keyboard") { // CSS class names object KeyboardCls : CssName() object KeyboardControlsCls : CssName() object KeyboardInfoCls : CssName() object KeyboardTitleCls : CssName() object KeyboardOctaveCls : CssName() object KeyboardKeysCls : CssName() object OctaveButtonCls : CssName() object WhiteKeyCls : CssName() object BlackKeyCls : CssName() object WhiteKeyPressedCls : CssName() object BlackKeyPressedCls : CssName() // Constants private const val OCTAVE_BASE = 5 private const val NOTES_PER_OCTAVE = 12 private const val BLACK_KEY_HEIGHT_PERCENTAGE = 60 private const val MIN_OCTAVE = 0 private const val MAX_OCTAVE = 9 private const val WHITE_KEYS_PER_OCTAVE = 7 private const val BLACK_KEYS_PER_OCTAVE = 5 private const val BLACK_KEY_WIDTH_DIVISOR = 9 private const val KEYBOARD_CONTROLS_HEIGHT = 60 // MIDI note numbers for C5 to B5 (one octave) private val BASE_WHITE_KEYS = listOf(60, 62, 64, 65, 67, 69, 71) // C, D, E, F, G, A, B private val BASE_BLACK_KEYS = listOf(61, 63, 66, 68, 70) // C#, D#, F#, G#, A# init { defineCss { select(cls(KeyboardCls)) { position(Position.relative) margin(5.px) select(cls(KeyboardControlsCls)) { position(Position.relative) display(Display.flex) flexDirection(FlexDirection.row) justifyContent(JustifyContent.spaceBetween) alignItems(AlignItems.center) width(100.prc) height(50.px) marginBottom(10.px) } select(cls(KeyboardInfoCls)) { display(Display.flex) flexDirection(FlexDirection.column) alignItems(AlignItems.center) justifyContent(JustifyContent.center) flex("1") } select(cls(KeyboardTitleCls)) { textAlign(TextAlign.center) fontSize(1.2.rem) color(Css.currentStyle.mainFontColor) } select(cls(KeyboardOctaveCls)) { textAlign(TextAlign.center) fontSize(1.0.rem) color(Css.currentStyle.mainFontColor) marginTop(5.px) } select(cls(OctaveButtonCls)) { height(50.px) plain("background-color", Css.currentStyle.buttonBackgroundColor.toString()) color(Css.currentStyle.mainFontColor) border("1px solid ${Css.currentStyle.buttonBorderColor}") textAlign(TextAlign.center) lineHeight(50.px) fontSize(1.5.rem) fontWeight(FontWeight.bold) cursor("pointer") plain("-webkit-touch-callout", "none") plain("-webkit-user-select", "none") plain("-moz-user-select", "none") plain("-ms-user-select", "none") plain("user-select", "none") } select(cls(KeyboardKeysCls)) { position(Position.relative) } } select(cls(WhiteKeyCls)) { plain("fill", "#FFFFFF") plain("stroke", "#000000") plain("stroke-width", "1") } select(cls(WhiteKeyPressedCls)) { plain("fill", "#E6E6E6") // 10% darker than white plain("stroke", "#000000") plain("stroke-width", "1") } select(cls(BlackKeyCls)) { plain("fill", "#000000") plain("stroke", "#000000") plain("stroke-width", "1") } select(cls(BlackKeyPressedCls)) { plain("fill", "#333333") // 10% lighter than black plain("stroke", "#000000") plain("stroke-width", "1") } } } } }