Compare commits
3 Commits
b48d6f3aca
...
770607d5e6
| Author | SHA1 | Date | |
|---|---|---|---|
| 770607d5e6 | |||
| e6b7c9b288 | |||
| 68a15bab8b |
@@ -10,7 +10,7 @@ plugins {
|
|||||||
}
|
}
|
||||||
|
|
||||||
group = "nl.astraeus"
|
group = "nl.astraeus"
|
||||||
version = "2.1.0-SNAPSHOT"
|
version = "2.1.0"
|
||||||
|
|
||||||
repositories {
|
repositories {
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
|
|||||||
110
src/jsMain/kotlin/nl/astraeus/vst/midi/Broadcaster.kt
Normal file
110
src/jsMain/kotlin/nl/astraeus/vst/midi/Broadcaster.kt
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
@file:OptIn(ExperimentalJsExport::class)
|
||||||
|
|
||||||
|
package nl.astraeus.vst.midi
|
||||||
|
|
||||||
|
import kotlinx.browser.window
|
||||||
|
import org.khronos.webgl.Uint8Array
|
||||||
|
import org.w3c.dom.BroadcastChannel
|
||||||
|
import org.w3c.dom.MessageEvent
|
||||||
|
import kotlin.math.min
|
||||||
|
|
||||||
|
@JsExport
|
||||||
|
enum class MessageType {
|
||||||
|
SYNC,
|
||||||
|
MIDI
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsExport
|
||||||
|
class SyncMessage(
|
||||||
|
@JsName("timeOrigin")
|
||||||
|
val timeOrigin: Double = window.performance.asDynamic().timeOrigin,
|
||||||
|
@JsName("now")
|
||||||
|
val now: Double = window.performance.now()
|
||||||
|
) {
|
||||||
|
@JsName("type")
|
||||||
|
val type: String = MessageType.SYNC.name
|
||||||
|
}
|
||||||
|
|
||||||
|
// time -> syncOrigin
|
||||||
|
// receive syn message
|
||||||
|
// syncOrigin = my timeOrigin - sync.timeOrigin
|
||||||
|
// - sync.timeOrigin = 50
|
||||||
|
// - my.timeOrigin = 100
|
||||||
|
// - syncOrigin = -50
|
||||||
|
|
||||||
|
// - sync.timeOrigin = 49
|
||||||
|
// - my.timeOrigin = 100
|
||||||
|
// - syncOrigin = update to -51
|
||||||
|
|
||||||
|
@JsExport
|
||||||
|
class MidiMessage(
|
||||||
|
@JsName("data")
|
||||||
|
val data: Uint8Array,
|
||||||
|
@JsName("timestamp")
|
||||||
|
val timestamp: dynamic = null,
|
||||||
|
@JsName("timeOrigin")
|
||||||
|
val timeOrigin: dynamic = window.performance.asDynamic().timeOrigin
|
||||||
|
) {
|
||||||
|
@JsName("type")
|
||||||
|
val type = MessageType.MIDI.name
|
||||||
|
}
|
||||||
|
|
||||||
|
object Sync {
|
||||||
|
var syncOrigin = 0.0
|
||||||
|
|
||||||
|
fun update(sync: SyncMessage) {
|
||||||
|
syncOrigin = min(syncOrigin, window.performance.asDynamic().timeOrigin - sync.timeOrigin)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun now(): Double = window.performance.now() + syncOrigin
|
||||||
|
}
|
||||||
|
|
||||||
|
object Broadcaster {
|
||||||
|
val channels = mutableMapOf<Int, BroadcastChannel>()
|
||||||
|
|
||||||
|
fun getChannel(channel: Int): BroadcastChannel = channels.getOrPut(channel) {
|
||||||
|
println("Opening broadcast channel $channel")
|
||||||
|
val bcChannel = BroadcastChannel("audio-worklet-$channel")
|
||||||
|
|
||||||
|
bcChannel.onmessage = { event ->
|
||||||
|
onMessage(channel, event)
|
||||||
|
}
|
||||||
|
|
||||||
|
bcChannel
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onMessage(channel: Int, event: MessageEvent) {
|
||||||
|
val data: dynamic = event.data.asDynamic()
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
"Received broadcast message on channel $channel",
|
||||||
|
event
|
||||||
|
)
|
||||||
|
if (data.type == MessageType.SYNC.name) {
|
||||||
|
val syncMessage = SyncMessage(
|
||||||
|
data.timeOrigin,
|
||||||
|
data.now
|
||||||
|
)
|
||||||
|
Sync.update(syncMessage)
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
"Received broadcast message on channel $channel",
|
||||||
|
event,
|
||||||
|
window.performance,
|
||||||
|
window.performance.now() - event.timeStamp.toDouble()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun send(channel: Int, message: Any) {
|
||||||
|
console.log("Sending broadcast message on channel $channel:", message)
|
||||||
|
getChannel(channel).postMessage(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sync() {
|
||||||
|
for (channel in channels.values) {
|
||||||
|
channel.postMessage(SyncMessage())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
160
src/jsMain/kotlin/nl/astraeus/vst/midi/Midi.kt
Normal file
160
src/jsMain/kotlin/nl/astraeus/vst/midi/Midi.kt
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
package nl.astraeus.vst.midi
|
||||||
|
|
||||||
|
import kotlinx.browser.window
|
||||||
|
import org.khronos.webgl.Uint8Array
|
||||||
|
import org.khronos.webgl.get
|
||||||
|
|
||||||
|
external class MIDIInput {
|
||||||
|
val connection: String
|
||||||
|
val id: String
|
||||||
|
val manufacturer: String
|
||||||
|
val name: String
|
||||||
|
val state: String
|
||||||
|
val type: String
|
||||||
|
val version: String
|
||||||
|
var onmidimessage: (dynamic) -> Unit
|
||||||
|
var onstatechange: (dynamic) -> Unit
|
||||||
|
|
||||||
|
fun open()
|
||||||
|
fun close()
|
||||||
|
}
|
||||||
|
|
||||||
|
external class MIDIOutput {
|
||||||
|
val connection: String
|
||||||
|
val id: String
|
||||||
|
val manufacturer: String
|
||||||
|
val name: String
|
||||||
|
val state: String
|
||||||
|
val type: String
|
||||||
|
val version: String
|
||||||
|
|
||||||
|
fun send(message: dynamic, timestamp: dynamic)
|
||||||
|
|
||||||
|
fun open()
|
||||||
|
fun close()
|
||||||
|
}
|
||||||
|
|
||||||
|
object Midi {
|
||||||
|
var outputChannel: Int = -1
|
||||||
|
|
||||||
|
var inputs = mutableListOf<MIDIInput>()
|
||||||
|
var outputs = mutableListOf<MIDIOutput>()
|
||||||
|
var currentInput: MIDIInput? = null
|
||||||
|
var currentOutput: MIDIOutput? = null
|
||||||
|
|
||||||
|
fun start(
|
||||||
|
onUpdate: () -> Unit,
|
||||||
|
) {
|
||||||
|
val navigator = window.navigator.asDynamic()
|
||||||
|
|
||||||
|
navigator.requestMIDIAccess().then(
|
||||||
|
{ midiAccess ->
|
||||||
|
val inp = midiAccess.inputs
|
||||||
|
val outp = midiAccess.outputs
|
||||||
|
|
||||||
|
console.log("Midi inputs:", inputs)
|
||||||
|
console.log("Midi outputs:", outputs)
|
||||||
|
|
||||||
|
inp.forEach() { input ->
|
||||||
|
console.log("Midi input:", input)
|
||||||
|
inputs.add(input)
|
||||||
|
console.log("Name: ${(input as? MIDIInput)?.name}")
|
||||||
|
}
|
||||||
|
|
||||||
|
outp.forEach() { output ->
|
||||||
|
console.log("Midi output:", output)
|
||||||
|
outputs.add(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
onUpdate()
|
||||||
|
},
|
||||||
|
{ e ->
|
||||||
|
println("Failed to get MIDI access - $e")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setInput(
|
||||||
|
id: String,
|
||||||
|
name: String = "",
|
||||||
|
onMidiInput: (data: Uint8Array) -> Unit
|
||||||
|
) {
|
||||||
|
var selected = inputs.find { it.id == id }
|
||||||
|
if (selected == null) {
|
||||||
|
var maxMatchChar = 0
|
||||||
|
inputs.forEach {
|
||||||
|
val matchChars = matchChars(it.name, name)
|
||||||
|
if (matchChars > maxMatchChar) {
|
||||||
|
selected = it
|
||||||
|
maxMatchChar = matchChars
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setInput(selected, onMidiInput)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun matchChars(str1: String, str2: String): Int {
|
||||||
|
var result = 0
|
||||||
|
if (str1.length > str2.length) {
|
||||||
|
for (ch in str1.toCharArray()) {
|
||||||
|
if (str2.contains(ch)) {
|
||||||
|
result++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (ch in str2.toCharArray()) {
|
||||||
|
if (str1.contains(ch)) {
|
||||||
|
result++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setInput(
|
||||||
|
input: MIDIInput?,
|
||||||
|
onMidiInput: (data: Uint8Array) -> Unit,
|
||||||
|
) {
|
||||||
|
console.log("Setting input", input)
|
||||||
|
currentInput?.close()
|
||||||
|
|
||||||
|
currentInput = input
|
||||||
|
|
||||||
|
currentInput?.onstatechange = { message ->
|
||||||
|
console.log("State change:", message)
|
||||||
|
}
|
||||||
|
|
||||||
|
currentInput?.onmidimessage = { message ->
|
||||||
|
val data = message.data as Uint8Array
|
||||||
|
val hex = StringBuilder()
|
||||||
|
for (index in 0 until data.length) {
|
||||||
|
hex.append(data[index].toString(16))
|
||||||
|
hex.append(" ")
|
||||||
|
}
|
||||||
|
console.log("Midi message:", hex)
|
||||||
|
|
||||||
|
onMidiInput(message.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
currentInput?.open()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setOutput(
|
||||||
|
output: MIDIOutput?
|
||||||
|
) {
|
||||||
|
console.log("Setting output", output)
|
||||||
|
currentOutput?.close()
|
||||||
|
|
||||||
|
currentOutput = output
|
||||||
|
|
||||||
|
currentOutput?.open()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun send(
|
||||||
|
data: Uint8Array,
|
||||||
|
timestamp: dynamic = null
|
||||||
|
) {
|
||||||
|
currentOutput?.send(data, timestamp)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -6,8 +6,14 @@ import kotlinx.html.js.onMouseLeaveFunction
|
|||||||
import kotlinx.html.js.onMouseUpFunction
|
import kotlinx.html.js.onMouseUpFunction
|
||||||
import kotlinx.html.style
|
import kotlinx.html.style
|
||||||
import kotlinx.html.svg
|
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.Position
|
||||||
import nl.astraeus.css.properties.TextAlign
|
import nl.astraeus.css.properties.TextAlign
|
||||||
|
import nl.astraeus.css.properties.prc
|
||||||
import nl.astraeus.css.properties.px
|
import nl.astraeus.css.properties.px
|
||||||
import nl.astraeus.css.properties.rem
|
import nl.astraeus.css.properties.rem
|
||||||
import nl.astraeus.css.style.cls
|
import nl.astraeus.css.style.cls
|
||||||
@@ -28,13 +34,26 @@ import org.w3c.dom.events.MouseEvent
|
|||||||
*/
|
*/
|
||||||
class KeyboardComponent(
|
class KeyboardComponent(
|
||||||
val title: String = "Keyboard",
|
val title: String = "Keyboard",
|
||||||
val octave: Int = 4,
|
initialOctave: Int = 4,
|
||||||
val keyboardWidth: Int = 210,
|
val keyboardWidth: Int = 210,
|
||||||
val keyboardHeight: Int = keyboardWidth / 2,
|
val keyboardHeight: Int = keyboardWidth / 2,
|
||||||
val onNoteDown: (Int) -> Unit = {},
|
val onNoteDown: (Int) -> Unit = {},
|
||||||
val onNoteUp: (Int) -> Unit = {}
|
val onNoteUp: (Int) -> Unit = {}
|
||||||
) : Komponent() {
|
) : Komponent() {
|
||||||
|
|
||||||
|
// Current octave with range validation
|
||||||
|
private var _octave: Int = initialOctave
|
||||||
|
var octave: Int
|
||||||
|
get() = _octave
|
||||||
|
set(value) {
|
||||||
|
_octave = when {
|
||||||
|
value < 0 -> 0
|
||||||
|
value > 9 -> 9
|
||||||
|
else -> value
|
||||||
|
}
|
||||||
|
requestUpdate()
|
||||||
|
}
|
||||||
|
|
||||||
// Set to track which notes are currently pressed
|
// Set to track which notes are currently pressed
|
||||||
private val pressedNotes = mutableSetOf<Int>()
|
private val pressedNotes = mutableSetOf<Int>()
|
||||||
|
|
||||||
@@ -87,14 +106,43 @@ class KeyboardComponent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
div(KeyboardTitleCls.name) {
|
div(KeyboardControlsCls.name) {
|
||||||
// Show title of the keyboard
|
// Decrease octave button
|
||||||
+title
|
div(OctaveButtonCls.name) {
|
||||||
}
|
style = "width: ${whiteKeyWidth}px"
|
||||||
|
+"<"
|
||||||
|
onMouseDownFunction = { event ->
|
||||||
|
if (event is MouseEvent) {
|
||||||
|
octave--
|
||||||
|
event.preventDefault()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
div(KeyboardOctaveCls.name) {
|
// Title and octave display container
|
||||||
// Show current octave the piano is being played at
|
div(KeyboardInfoCls.name) {
|
||||||
+"Octave: $octave"
|
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) {
|
||||||
|
octave++
|
||||||
|
event.preventDefault()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
div(KeyboardKeysCls.name) {
|
div(KeyboardKeysCls.name) {
|
||||||
@@ -115,7 +163,7 @@ class KeyboardComponent(
|
|||||||
var blackKeyPressed = false
|
var blackKeyPressed = false
|
||||||
for (j in 0 until 5) {
|
for (j in 0 until 5) {
|
||||||
if (x >= blackKeyPositions[j] && x <= blackKeyPositions[j] + blackKeyWidth) {
|
if (x >= blackKeyPositions[j] && x <= blackKeyPositions[j] + blackKeyWidth) {
|
||||||
noteDown(blackKeys[j] + (octave - 4) * 12)
|
noteDown(blackKeys[j] + (octave - 5) * 12)
|
||||||
blackKeyPressed = true
|
blackKeyPressed = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -126,14 +174,14 @@ class KeyboardComponent(
|
|||||||
// Check if click is on a white key
|
// Check if click is on a white key
|
||||||
val keyIndex = (x / whiteKeyWidth).toInt()
|
val keyIndex = (x / whiteKeyWidth).toInt()
|
||||||
if (keyIndex in 0..6) {
|
if (keyIndex in 0..6) {
|
||||||
noteDown(whiteKeys[keyIndex] + (octave - 4) * 12)
|
noteDown(whiteKeys[keyIndex] + (octave - 5) * 12)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// If y > blackKeyHeight, it's definitely a white key
|
// If y > blackKeyHeight, it's definitely a white key
|
||||||
val keyIndex = (x / whiteKeyWidth).toInt()
|
val keyIndex = (x / whiteKeyWidth).toInt()
|
||||||
if (keyIndex in 0..6) {
|
if (keyIndex in 0..6) {
|
||||||
noteDown(whiteKeys[keyIndex] + (octave - 4) * 12)
|
noteDown(whiteKeys[keyIndex] + (octave - 5) * 12)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,7 +199,7 @@ class KeyboardComponent(
|
|||||||
var blackKeyReleased = false
|
var blackKeyReleased = false
|
||||||
for (j in 0 until 5) {
|
for (j in 0 until 5) {
|
||||||
if (x >= blackKeyPositions[j] && x <= blackKeyPositions[j] + blackKeyWidth) {
|
if (x >= blackKeyPositions[j] && x <= blackKeyPositions[j] + blackKeyWidth) {
|
||||||
noteUp(blackKeys[j] + (octave - 4) * 12)
|
noteUp(blackKeys[j] + (octave - 5) * 12)
|
||||||
blackKeyReleased = true
|
blackKeyReleased = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -162,14 +210,14 @@ class KeyboardComponent(
|
|||||||
// Check if release is on a white key
|
// Check if release is on a white key
|
||||||
val keyIndex = (x / whiteKeyWidth).toInt()
|
val keyIndex = (x / whiteKeyWidth).toInt()
|
||||||
if (keyIndex in 0..6) {
|
if (keyIndex in 0..6) {
|
||||||
noteUp(whiteKeys[keyIndex] + (octave - 4) * 12)
|
noteUp(whiteKeys[keyIndex] + (octave - 5) * 12)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// If y > blackKeyHeight, it's definitely a white key
|
// If y > blackKeyHeight, it's definitely a white key
|
||||||
val keyIndex = (x / whiteKeyWidth).toInt()
|
val keyIndex = (x / whiteKeyWidth).toInt()
|
||||||
if (keyIndex in 0..6) {
|
if (keyIndex in 0..6) {
|
||||||
noteUp(whiteKeys[keyIndex] + (octave - 4) * 12)
|
noteUp(whiteKeys[keyIndex] + (octave - 5) * 12)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,9 +255,12 @@ class KeyboardComponent(
|
|||||||
|
|
||||||
companion object : CssId("keyboard") {
|
companion object : CssId("keyboard") {
|
||||||
object KeyboardCls : CssName()
|
object KeyboardCls : CssName()
|
||||||
|
object KeyboardControlsCls : CssName()
|
||||||
|
object KeyboardInfoCls : CssName()
|
||||||
object KeyboardTitleCls : CssName()
|
object KeyboardTitleCls : CssName()
|
||||||
object KeyboardOctaveCls : CssName()
|
object KeyboardOctaveCls : CssName()
|
||||||
object KeyboardKeysCls : CssName()
|
object KeyboardKeysCls : CssName()
|
||||||
|
object OctaveButtonCls : CssName()
|
||||||
object WhiteKeyCls : CssName()
|
object WhiteKeyCls : CssName()
|
||||||
object BlackKeyCls : CssName()
|
object BlackKeyCls : CssName()
|
||||||
|
|
||||||
@@ -219,31 +270,57 @@ class KeyboardComponent(
|
|||||||
position(Position.relative)
|
position(Position.relative)
|
||||||
margin(5.px)
|
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)) {
|
select(cls(KeyboardTitleCls)) {
|
||||||
position(Position.absolute)
|
|
||||||
width(100.px)
|
|
||||||
textAlign(TextAlign.center)
|
textAlign(TextAlign.center)
|
||||||
fontSize(1.2.rem)
|
fontSize(1.2.rem)
|
||||||
color(Css.currentStyle.mainFontColor)
|
color(Css.currentStyle.mainFontColor)
|
||||||
top(5.px)
|
|
||||||
left(0.px)
|
|
||||||
right(0.px)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
select(cls(KeyboardOctaveCls)) {
|
select(cls(KeyboardOctaveCls)) {
|
||||||
position(Position.absolute)
|
|
||||||
width(100.px)
|
|
||||||
textAlign(TextAlign.center)
|
textAlign(TextAlign.center)
|
||||||
fontSize(1.0.rem)
|
fontSize(1.0.rem)
|
||||||
color(Css.currentStyle.mainFontColor)
|
color(Css.currentStyle.mainFontColor)
|
||||||
top(30.px)
|
marginTop(5.px)
|
||||||
left(0.px)
|
}
|
||||||
right(0.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)) {
|
select(cls(KeyboardKeysCls)) {
|
||||||
position(Position.absolute)
|
position(Position.relative)
|
||||||
top(60.px)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user