Fix html input renderers

This commit is contained in:
Alexander Nozik 2023-12-02 23:01:15 +03:00
parent 469655092e
commit 7561ddad36
7 changed files with 234 additions and 79 deletions

View File

@ -2,10 +2,8 @@ package space.kscience.visionforge
import kotlinx.coroutines.launch
import space.kscience.dataforge.context.Plugin
import space.kscience.dataforge.meta.Meta
import space.kscience.dataforge.meta.MetaRepr
import space.kscience.dataforge.names.Name
import space.kscience.dataforge.names.parseAsName
/**
* A feedback client that communicates with a server and provides ability to propagate events and changes back to the model
@ -15,25 +13,25 @@ public interface VisionClient: Plugin {
public suspend fun sendEvent(targetName: Name, event: VisionEvent)
public fun notifyPropertyChanged(visionName: Name, propertyName: Name, item: Meta?)
// public fun notifyPropertyChanged(visionName: Name, propertyName: Name, item: Meta?)
}
public fun VisionClient.notifyPropertyChanged(visionName: Name, propertyName: String, item: Meta?) {
notifyPropertyChanged(visionName, propertyName.parseAsName(true), item)
}
public fun VisionClient.notifyPropertyChanged(visionName: Name, propertyName: String, item: Number) {
notifyPropertyChanged(visionName, propertyName.parseAsName(true), Meta(item))
}
public fun VisionClient.notifyPropertyChanged(visionName: Name, propertyName: String, item: String) {
notifyPropertyChanged(visionName, propertyName.parseAsName(true), Meta(item))
}
public fun VisionClient.notifyPropertyChanged(visionName: Name, propertyName: String, item: Boolean) {
notifyPropertyChanged(visionName, propertyName.parseAsName(true), Meta(item))
}
//public fun VisionClient.notifyPropertyChanged(visionName: Name, propertyName: String, item: Meta?) {
// notifyPropertyChanged(visionName, propertyName.parseAsName(true), item)
//}
//
//public fun VisionClient.notifyPropertyChanged(visionName: Name, propertyName: String, item: Number) {
// notifyPropertyChanged(visionName, propertyName.parseAsName(true), Meta(item))
//}
//
//public fun VisionClient.notifyPropertyChanged(visionName: Name, propertyName: String, item: String) {
// notifyPropertyChanged(visionName, propertyName.parseAsName(true), Meta(item))
//}
//
//public fun VisionClient.notifyPropertyChanged(visionName: Name, propertyName: String, item: Boolean) {
// notifyPropertyChanged(visionName, propertyName.parseAsName(true), Meta(item))
//}
public fun VisionClient.sendEvent(targetName: Name, payload: MetaRepr): Unit {
context.launch {

View File

@ -13,10 +13,7 @@ import space.kscience.dataforge.meta.descriptors.MetaDescriptor
import space.kscience.dataforge.meta.toJson
import space.kscience.dataforge.meta.toMeta
import space.kscience.dataforge.names.Name
import space.kscience.visionforge.html.VisionOfCheckbox
import space.kscience.visionforge.html.VisionOfHtmlForm
import space.kscience.visionforge.html.VisionOfNumberField
import space.kscience.visionforge.html.VisionOfTextField
import space.kscience.visionforge.html.*
public class VisionManager(meta: Meta) : AbstractPlugin(meta), MutableVisionContainer<Vision> {
override val tag: PluginTag get() = Companion.tag
@ -72,9 +69,11 @@ public class VisionManager(meta: Meta) : AbstractPlugin(meta), MutableVisionCont
defaultDeserializer { SimpleVisionGroup.serializer() }
subclass(NullVision.serializer())
subclass(SimpleVisionGroup.serializer())
subclass(VisionOfHtmlInput.serializer())
subclass(VisionOfNumberField.serializer())
subclass(VisionOfTextField.serializer())
subclass(VisionOfCheckbox.serializer())
subclass(VisionOfRangeField.serializer())
subclass(VisionOfHtmlForm.serializer())
}
}

View File

@ -1,6 +1,8 @@
package space.kscience.visionforge.html
import kotlinx.html.InputType
import kotlinx.html.TagConsumer
import kotlinx.html.stream.createHTML
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import space.kscience.dataforge.meta.*
@ -9,16 +11,45 @@ import space.kscience.visionforge.AbstractVision
@Serializable
public abstract class VisionOfHtml: AbstractVision(){
public abstract class VisionOfHtml : AbstractVision() {
public var classes: List<String> by properties.stringList(*emptyArray())
}
@Serializable
@SerialName("html.plain")
public class VisionOfPlainHtml : VisionOfHtml() {
public var content: String? by properties.string()
}
public inline fun VisionOfPlainHtml.content(block: TagConsumer<*>.() -> Unit) {
content = createHTML().apply(block).finalize()
}
@Serializable
public enum class InputFeedbackMode{
/**
* Fire feedback event on `onchange` event
*/
ONCHANGE,
/**
* Fire feedback event on `oninput` event
*/
ONINPUT,
/**
* provide only manual feedback
*/
NONE
}
@Serializable
@SerialName("html.input")
public open class VisionOfHtmlInput(
public val inputType: String,
public val feedbackMode: InputFeedbackMode = InputFeedbackMode.ONCHANGE
) : VisionOfHtml() {
public var value : Value? by properties.value()
public var value: Value? by properties.value()
public var disabled: Boolean by properties.boolean { false }
public var fieldName: String? by properties.string()
}

View File

@ -39,9 +39,22 @@ public fun Vision.useProperty(
callback: (Meta) -> Unit,
): Job = useProperty(propertyName.parseAsName(), inherit, includeStyles, scope, callback)
/**
* Observe changes to the specific property without passing the initial value.
*/
public fun <V : Vision, T> V.onPropertyChange(
property: KProperty1<V, T>,
scope: CoroutineScope = manager?.context ?: error("Orphan Vision can't observe properties"),
callback: suspend V.(T) -> Unit,
): Job = properties.changes.onEach { name ->
if (name.startsWith(property.name.asName())) {
callback(property.get(this))
}
}.launchIn(scope)
public fun <V : Vision, T> V.useProperty(
property: KProperty1<V, T>,
scope: CoroutineScope? = manager?.context,
scope: CoroutineScope = manager?.context ?: error("Orphan Vision can't observe properties"),
callback: V.(T) -> Unit,
): Job {
//Pass initial value.
@ -50,5 +63,5 @@ public fun <V : Vision, T> V.useProperty(
if (name.startsWith(property.name.asName())) {
callback(property.get(this@useProperty))
}
}.launchIn(scope ?: error("Orphan Vision can't observe properties"))
}.launchIn(scope)
}

View File

@ -26,7 +26,7 @@ public interface ElementVisionRenderer : Named {
/**
* Give a [vision] integer rating based on this renderer capabilities. [ZERO_RATING] or negative values means that this renderer
* can't process a vision. The value of [DEFAULT_RATING] used for default renderer. Specialized renderers could specify
* higher value in order to "steal" rendering job
* higher value to "steal" rendering job
*/
public fun rateVision(vision: Vision): Int

View File

@ -65,20 +65,20 @@ public class JsVisionClient : AbstractPlugin(), VisionClient {
private fun Element.getFlag(attribute: String): Boolean = attributes[attribute]?.value != null
private val mutex = Mutex()
// private val mutex = Mutex()
private val changeCollector = VisionChangeBuilder()
/**
* Communicate vision property changed from rendering engine to model
*/
override fun notifyPropertyChanged(visionName: Name, propertyName: Name, item: Meta?) {
context.launch {
mutex.withLock {
changeCollector.propertyChanged(visionName, propertyName, item)
}
}
}
// /**
// * Communicate vision property changed from rendering engine to model
// */
// private fun notifyPropertyChanged(visionName: Name, propertyName: Name, item: Meta?) {
// context.launch {
// mutex.withLock {
// changeCollector.propertyChanged(visionName, propertyName, item)
// }
// }
// }
private val eventCollector by lazy {
MutableSharedFlow<Pair<Name, VisionEvent>>(meta["feedback.eventCache"].int ?: 100)
@ -97,7 +97,7 @@ public class JsVisionClient : AbstractPlugin(), VisionClient {
renderer.render(element, name, vision, outputMeta)
}
private fun startVisionUpdate(element: Element, name: Name, vision: Vision?, outputMeta: Meta) {
private fun startVisionUpdate(element: Element, visionName: Name, vision: Vision, outputMeta: Meta) {
element.attributes[OUTPUT_CONNECT_ATTRIBUTE]?.let { attr ->
val wsUrl = if (attr.value.isBlank() || attr.value == VisionTagConsumer.AUTO_DATA_ATTRIBUTE) {
val endpoint = resolveEndpoint(element)
@ -109,9 +109,10 @@ public class JsVisionClient : AbstractPlugin(), VisionClient {
URL(attr.value)
}.apply {
protocol = "ws"
searchParams.append("name", name.toString())
searchParams.append("name", visionName.toString())
}
logger.info { "Updating vision data from $wsUrl" }
//Individual websocket for this vision
@ -125,14 +126,13 @@ public class JsVisionClient : AbstractPlugin(), VisionClient {
)
// If change contains root vision replacement, do it
if(event is VisionChange) {
if (event is VisionChange) {
event.vision?.let { vision ->
renderVision(element, name, vision, outputMeta)
renderVision(element, visionName, vision, outputMeta)
}
}
logger.debug { "Got $event for output with name $name" }
if (vision == null) error("Can't update vision because it is not loaded.")
logger.debug { "Got $event for output with name $visionName" }
vision.receiveEvent(event)
} else {
logger.error { "WebSocket message data is not a string" }
@ -147,32 +147,44 @@ public class JsVisionClient : AbstractPlugin(), VisionClient {
val feedbackAggregationTime = meta["feedback.aggregationTime"]?.int ?: 300
onopen = {
val mutex = Mutex()
val changeCollector = VisionChangeBuilder()
feedbackJob = visionManager.context.launch {
eventCollector.filter { it.first == name }.onEach {
//launch a separate coroutine to send events to the backend
eventCollector.filter { it.first == visionName }.onEach {
send(visionManager.jsonFormat.encodeToString(VisionEvent.serializer(), it.second))
}.launchIn(this)
//launch backward property propagation
vision.properties.changes.onEach { propertyName: Name ->
changeCollector.propertyChanged(visionName, propertyName, vision.properties.getMeta(propertyName))
}.launchIn(this)
//aggregate atomic changes
while (isActive) {
delay(feedbackAggregationTime.milliseconds)
val change = changeCollector[name] ?: continue
if (!change.isEmpty()) {
if (!changeCollector.isEmpty()) {
mutex.withLock {
eventCollector.emit(name to change.deepCopy(visionManager))
change.reset()
eventCollector.emit(visionName to changeCollector.deepCopy(visionManager))
changeCollector.reset()
}
}
}
}
logger.info { "WebSocket feedback channel established for output '$name'" }
logger.info { "WebSocket feedback channel established for output '$visionName'" }
}
onclose = {
feedbackJob?.cancel()
logger.info { "WebSocket feedback channel closed for output '$name'" }
logger.info { "WebSocket feedback channel closed for output '$visionName'" }
}
onerror = {
feedbackJob?.cancel()
logger.error { "WebSocket feedback channel error for output '$name'" }
logger.error { "WebSocket feedback channel error for output '$visionName'" }
}
}
}
@ -241,9 +253,9 @@ public class JsVisionClient : AbstractPlugin(), VisionClient {
}
//Try to load vision via websocket
element.attributes[OUTPUT_CONNECT_ATTRIBUTE] != null -> {
startVisionUpdate(element, name, null, outputMeta)
}
// element.attributes[OUTPUT_CONNECT_ATTRIBUTE] != null -> {
// startVisionUpdate(element, name, null, outputMeta)
// }
else -> error("No embedded vision data / fetch url for $name")
}
@ -252,9 +264,12 @@ public class JsVisionClient : AbstractPlugin(), VisionClient {
override fun content(target: String): Map<Name, Any> = if (target == ElementVisionRenderer.TYPE) {
listOf(
numberVisionRenderer(),
textVisionRenderer(),
formVisionRenderer()
inputVisionRenderer,
checkboxVisionRenderer,
numberVisionRenderer,
textVisionRenderer,
rangeVisionRenderer,
formVisionRenderer
).associateByName()
} else super<AbstractPlugin>.content(target)

View File

@ -3,10 +3,10 @@ package space.kscience.visionforge
import kotlinx.browser.document
import kotlinx.html.InputType
import kotlinx.html.js.input
import kotlinx.html.js.onChangeFunction
import org.w3c.dom.HTMLElement
import org.w3c.dom.HTMLFormElement
import org.w3c.dom.HTMLInputElement
import org.w3c.dom.events.Event
import org.w3c.dom.get
import org.w3c.xhr.FormData
import space.kscience.dataforge.context.debug
@ -14,7 +14,11 @@ import space.kscience.dataforge.context.logger
import space.kscience.dataforge.meta.*
import space.kscience.visionforge.html.*
/**
* Subscribes the HTML element to a given vision.
*
* @param vision The vision to subscribe to.
*/
private fun HTMLElement.subscribeToVision(vision: VisionOfHtml) {
vision.useProperty(VisionOfHtml::classes) {
classList.value = classes.joinToString(separator = " ")
@ -22,6 +26,11 @@ private fun HTMLElement.subscribeToVision(vision: VisionOfHtml) {
}
/**
* Subscribes the HTML input element to a given vision.
*
* @param inputVision The input vision to subscribe to.
*/
private fun HTMLInputElement.subscribeToInput(inputVision: VisionOfHtmlInput) {
subscribeToVision(inputVision)
inputVision.useProperty(VisionOfHtmlInput::disabled) {
@ -29,33 +38,123 @@ private fun HTMLInputElement.subscribeToInput(inputVision: VisionOfHtmlInput) {
}
}
internal fun JsVisionClient.textVisionRenderer(): ElementVisionRenderer =
ElementVisionRenderer<VisionOfTextField> { visionName, vision, _ ->
internal val inputVisionRenderer: ElementVisionRenderer =
ElementVisionRenderer<VisionOfHtmlInput>(acceptRating = ElementVisionRenderer.DEFAULT_RATING - 1) { _, vision, _ ->
input {
type = InputType.text
onChangeFunction = {
notifyPropertyChanged(visionName, VisionOfTextField::text.name, value)
}
}.apply {
val onEvent: (Event) -> Unit = {
vision.value = value.asValue()
}
when (vision.feedbackMode) {
InputFeedbackMode.ONCHANGE -> onchange = onEvent
InputFeedbackMode.ONINPUT -> oninput = onEvent
InputFeedbackMode.NONE -> {}
}
subscribeToInput(vision)
vision.useProperty(VisionOfTextField::text) {
value = (it ?: "").asValue()
vision.useProperty(VisionOfHtmlInput::value) {
this@apply.value = it?.string ?: ""
}
}
}
internal fun JsVisionClient.numberVisionRenderer(): ElementVisionRenderer =
ElementVisionRenderer<VisionOfNumberField> { visionName, vision, _ ->
internal val checkboxVisionRenderer: ElementVisionRenderer =
ElementVisionRenderer<VisionOfCheckbox> { _, vision, _ ->
input {
type = InputType.checkBox
}.apply {
val onEvent: (Event) -> Unit = {
vision.checked = checked
}
when (vision.feedbackMode) {
InputFeedbackMode.ONCHANGE -> onchange = onEvent
InputFeedbackMode.ONINPUT -> oninput = onEvent
InputFeedbackMode.NONE -> {}
}
subscribeToInput(vision)
vision.useProperty(VisionOfCheckbox::checked) {
this@apply.checked = it ?: false
}
}
}
internal val textVisionRenderer: ElementVisionRenderer =
ElementVisionRenderer<VisionOfTextField> { _, vision, _ ->
input {
type = InputType.text
onChangeFunction = {
notifyPropertyChanged(visionName, VisionOfNumberField::value.name, value)
}
}.apply {
val onEvent: (Event) -> Unit = {
vision.text = value
}
when (vision.feedbackMode) {
InputFeedbackMode.ONCHANGE -> onchange = onEvent
InputFeedbackMode.ONINPUT -> oninput = onEvent
InputFeedbackMode.NONE -> {}
}
subscribeToInput(vision)
vision.useProperty(VisionOfTextField::text) {
this@apply.value = it ?: ""
}
}
}
internal val numberVisionRenderer: ElementVisionRenderer =
ElementVisionRenderer<VisionOfNumberField> { _, vision, _ ->
input {
type = InputType.text
}.apply {
val onEvent: (Event) -> Unit = {
value.toDoubleOrNull()?.let { vision.number = it }
}
when (vision.feedbackMode) {
InputFeedbackMode.ONCHANGE -> onchange = onEvent
InputFeedbackMode.ONINPUT -> oninput = onEvent
InputFeedbackMode.NONE -> {}
}
subscribeToInput(vision)
vision.useProperty(VisionOfNumberField::value) {
value = (it?.double ?: 0.0).asValue()
this@apply.valueAsNumber = it?.double ?: 0.0
}
}
}
internal val rangeVisionRenderer: ElementVisionRenderer =
ElementVisionRenderer<VisionOfRangeField> { _, vision, _ ->
input {
type = InputType.text
min = vision.min.toString()
max = vision.max.toString()
step = vision.step.toString()
}.apply {
val onEvent: (Event) -> Unit = {
value.toDoubleOrNull()?.let { vision.number = it }
}
when (vision.feedbackMode) {
InputFeedbackMode.ONCHANGE -> onchange = onEvent
InputFeedbackMode.ONINPUT -> oninput = onEvent
InputFeedbackMode.NONE -> {}
}
subscribeToInput(vision)
vision.useProperty(VisionOfRangeField::value) {
this@apply.valueAsNumber = it?.double ?: 0.0
}
}
}
@ -83,7 +182,7 @@ internal fun FormData.toMeta(): Meta {
return DynamicMeta(`object`)
}
internal fun JsVisionClient.formVisionRenderer(): ElementVisionRenderer =
internal val formVisionRenderer: ElementVisionRenderer =
ElementVisionRenderer<VisionOfHtmlForm> { visionName, vision, _ ->
val form = document.getElementById(vision.formId) as? HTMLFormElement
@ -91,10 +190,10 @@ internal fun JsVisionClient.formVisionRenderer(): ElementVisionRenderer =
form.subscribeToVision(vision)
logger.debug { "Adding hooks to form with id = '$vision.formId'" }
vision.manager?.logger?.debug { "Adding hooks to form with id = '$vision.formId'" }
vision.useProperty(VisionOfHtmlForm::values) { values ->
logger.debug { "Updating form '${vision.formId}' with values $values" }
vision.manager?.logger?.debug { "Updating form '${vision.formId}' with values $values" }
val inputs = form.getElementsByTagName("input")
values?.valueSequence()?.forEach { (token, value) ->
(inputs[token.toString()] as? HTMLInputElement)?.value = value.toString()
@ -104,7 +203,7 @@ internal fun JsVisionClient.formVisionRenderer(): ElementVisionRenderer =
form.onsubmit = { event ->
event.preventDefault()
val formData = FormData(form).toMeta()
notifyPropertyChanged(visionName, VisionOfHtmlForm::values.name, formData)
vision.values = formData
console.info("Sent: ${formData.toMap()}")
false
}