diff --git a/ui/compose/src/jsMain/kotlin/space/kscience/visionforge/compose/NameCrumbs.kt b/ui/compose/src/jsMain/kotlin/space/kscience/visionforge/compose/NameCrumbs.kt new file mode 100644 index 00000000..2becdbb6 --- /dev/null +++ b/ui/compose/src/jsMain/kotlin/space/kscience/visionforge/compose/NameCrumbs.kt @@ -0,0 +1,44 @@ +package space.kscience.visionforge.compose + +import androidx.compose.runtime.Composable +import org.jetbrains.compose.web.dom.Li +import org.jetbrains.compose.web.dom.Nav +import org.jetbrains.compose.web.dom.Ol +import org.jetbrains.compose.web.dom.Text +import space.kscience.dataforge.names.Name +import space.kscience.dataforge.names.NameToken +import space.kscience.dataforge.names.length + +@Composable +public fun NameCrumbs(name: Name?, link: (Name) -> Unit): Unit = Nav({ + attr("aria-label","breadcrumb") +}) { + Ol({classes("breadcrumb")}) { + Li({ + classes("breadcrumb-item") + onClick { + link(Name.EMPTY) + } + }) { + Text("\u2302") + } + + if (name != null) { + val tokens = ArrayList(name.length) + name.tokens.forEach { token -> + tokens.add(token) + val fullName = Name(tokens.toList()) + Text(".") + Li({ + classes("breadcrumb-item") + if(tokens.size == name.length) classes("active") + onClick { + link(fullName) + } + }) { + Text(token.toString()) + } + } + } + } +} \ No newline at end of file diff --git a/ui/compose/src/jsMain/kotlin/space/kscience/visionforge/compose/PropertyEditor.kt b/ui/compose/src/jsMain/kotlin/space/kscience/visionforge/compose/PropertyEditor.kt index f82b9e05..d919cd77 100644 --- a/ui/compose/src/jsMain/kotlin/space/kscience/visionforge/compose/PropertyEditor.kt +++ b/ui/compose/src/jsMain/kotlin/space/kscience/visionforge/compose/PropertyEditor.kt @@ -21,10 +21,7 @@ import space.kscience.dataforge.meta.descriptors.ValueRequirement import space.kscience.dataforge.meta.descriptors.get import space.kscience.dataforge.meta.get import space.kscience.dataforge.meta.remove -import space.kscience.dataforge.names.Name -import space.kscience.dataforge.names.NameToken -import space.kscience.dataforge.names.isEmpty -import space.kscience.dataforge.names.lastOrNull +import space.kscience.dataforge.names.* import space.kscience.visionforge.hidden @@ -39,19 +36,17 @@ public sealed class EditorPropertyState { } /** + * @param meta Root config object - always non-null * @param rootDescriptor Full path to the displayed node in [meta]. Could be empty */ @Composable -private fun PropertyEditorItem( - /** - * Root config object - always non-null - */ +public fun PropertyEditor( + scope: CoroutineScope, meta: MutableMeta, getPropertyState: (Name) -> EditorPropertyState, - scope: CoroutineScope, updates: Flow, - name: Name, - rootDescriptor: MetaDescriptor?, + name: Name = Name.EMPTY, + rootDescriptor: MetaDescriptor? = null, initialExpanded: Boolean? = null, ) { var expanded: Boolean by remember { mutableStateOf(initialExpanded ?: true) } @@ -145,7 +140,7 @@ private fun PropertyEditorItem( Div({ classes(TreeStyles.treeItem) }) { - PropertyEditorItem(meta, getPropertyState, scope, updates, name, descriptor, expanded) + PropertyEditor(scope, meta, getPropertyState, updates, name + token, descriptor, expanded) } } } @@ -159,7 +154,8 @@ public fun PropertyEditor( descriptor: MetaDescriptor? = null, expanded: Boolean? = null, ) { - PropertyEditorItem( + PropertyEditor( + scope = scope, meta = properties, getPropertyState = { name -> if (properties[name] != null) { @@ -170,7 +166,6 @@ public fun PropertyEditor( EditorPropertyState.Undefined } }, - scope = scope, updates = callbackFlow { properties.onChange(scope) { name -> scope.launch { diff --git a/ui/compose/src/jsMain/kotlin/space/kscience/visionforge/compose/Tabs.kt b/ui/compose/src/jsMain/kotlin/space/kscience/visionforge/compose/Tabs.kt new file mode 100644 index 00000000..cae4e797 --- /dev/null +++ b/ui/compose/src/jsMain/kotlin/space/kscience/visionforge/compose/Tabs.kt @@ -0,0 +1,102 @@ +package space.kscience.visionforge.compose + +import androidx.compose.runtime.* +import org.jetbrains.compose.web.dom.* +import org.w3c.dom.HTMLDivElement +import org.w3c.dom.HTMLLIElement + + +public class ComposeTab( + public val key: String, + public val title: String, + public val content: ContentBuilder, + public val disabled: Boolean, + public val titleExt: ContentBuilder, +) + +@Composable +public fun Tabs(tabs: List, activeKey: String) { + var active by remember(activeKey) { mutableStateOf(activeKey) } + + Div({ classes("card", "text-center") }) { + Div({ classes("card-header") }) { + + Ul({ classes("nav", "nav-tabs", "card-header-tabs") }) { + tabs.forEach { tab -> + Li({ + classes("nav-item") + }) { + A(attrs = { + classes("nav-link") + if (active == tab.key) { + classes("active") + } + if (tab.disabled) { + classes("disabled") + } + onClick { + active = tab.key + } + }) { + Text(tab.title) + } + tab.titleExt.invoke(this) + } + } + } + } + tabs.find { it.key == active }?.let { tab -> + Div({ classes("card-body") }) { + tab.content.invoke(this) + } + } + } + + +} + +public class TabBuilder internal constructor(public val key: String) { + private var title: String = key + public var disabled: Boolean = false + private var content: ContentBuilder = {} + private var titleExt: ContentBuilder = {} + + @Composable + public fun Content(content: ContentBuilder) { + this.content = content + } + + @Composable + public fun Title(title: String, titleExt: ContentBuilder = {}) { + this.title = title + this.titleExt = titleExt + } + + internal fun build(): ComposeTab = ComposeTab( + key, + title, + content, + disabled, + titleExt + ) +} + +public class TabsBuilder { + public var active: String = "" + internal val tabs: MutableList = mutableListOf() + + @Composable + public fun Tab(key: String, builder: @Composable TabBuilder.() -> Unit) { + tabs.add(TabBuilder(key).apply { builder() }.build()) + } + + public fun addTab(tab: ComposeTab) { + tabs.add(tab) + } +} + +@Composable +public fun Tabs(builder: @Composable TabsBuilder.() -> Unit) { + val result = TabsBuilder().apply { builder() } + Tabs(result.tabs, result.active) +} \ No newline at end of file diff --git a/ui/compose/src/jsMain/kotlin/space/kscience/visionforge/compose/ThreeControls.kt b/ui/compose/src/jsMain/kotlin/space/kscience/visionforge/compose/ThreeControls.kt new file mode 100644 index 00000000..c559b946 --- /dev/null +++ b/ui/compose/src/jsMain/kotlin/space/kscience/visionforge/compose/ThreeControls.kt @@ -0,0 +1,80 @@ +package space.kscience.visionforge.compose + +import androidx.compose.runtime.Composable +import org.jetbrains.compose.web.css.* +import org.jetbrains.compose.web.dom.Button +import org.jetbrains.compose.web.dom.Text +import org.w3c.files.Blob +import org.w3c.files.BlobPropertyBag +import space.kscience.dataforge.context.Global +import space.kscience.dataforge.names.Name +import space.kscience.visionforge.Vision +import space.kscience.visionforge.encodeToString +import space.kscience.visionforge.solid.specifications.Canvas3DOptions + +@Composable +internal fun CanvasControls( + vision: Vision?, + options: Canvas3DOptions, +) { + FlexColumn { + FlexRow({ + style { + border { + width(1.px) + style(LineStyle.Solid) + color(Color("blue")) + } + padding(4.px) + } + }) { + vision?.let { vision -> + Button({ + onClick { event -> + val json = vision.encodeToString() + event.stopPropagation(); + event.preventDefault(); + + val fileSaver = kotlinext.js.require("file-saver") + val blob = Blob(arrayOf(json), BlobPropertyBag("text/json;charset=utf-8")) + fileSaver.saveAs(blob, "object.json") as Unit + } + }) { + Text("Export") + } + } + } + PropertyEditor( + scope = vision?.manager?.context ?: Global, + properties = options.meta, + descriptor = Canvas3DOptions.descriptor, + expanded = false + ) + + } +} + + +@Composable +public fun ThreeControls( + vision: Vision?, + canvasOptions: Canvas3DOptions, + selected: Name?, + onSelect: (Name?) -> Unit, + tabBuilder: @Composable TabsBuilder.() -> Unit = {}, +) { + Tabs { + active = "Tree" + vision?.let { vision -> + Tab("Tree") { + CardTitle("Vision tree") + VisionTree(vision, Name.EMPTY, selected, onSelect) + } + } + Tab("Settings") { + CardTitle("Canvas configuration") + CanvasControls(vision, canvasOptions) + } + tabBuilder() + } +} diff --git a/ui/compose/src/jsMain/kotlin/space/kscience/visionforge/compose/ThreeViewWithControls.kt b/ui/compose/src/jsMain/kotlin/space/kscience/visionforge/compose/ThreeViewWithControls.kt new file mode 100644 index 00000000..7a347110 --- /dev/null +++ b/ui/compose/src/jsMain/kotlin/space/kscience/visionforge/compose/ThreeViewWithControls.kt @@ -0,0 +1,169 @@ +@file:OptIn(ExperimentalComposeWebApi::class) + +package space.kscience.visionforge.compose + +import androidx.compose.runtime.* +import app.softwork.bootstrapcompose.Card +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.launch +import org.jetbrains.compose.web.ExperimentalComposeWebApi +import org.jetbrains.compose.web.css.* +import org.jetbrains.compose.web.dom.* +import space.kscience.dataforge.meta.get +import space.kscience.dataforge.names.Name +import space.kscience.dataforge.names.isEmpty +import space.kscience.visionforge.* +import space.kscience.visionforge.solid.Solid +import space.kscience.visionforge.solid.SolidGroup +import space.kscience.visionforge.solid.Solids +import space.kscience.visionforge.solid.specifications.Canvas3DOptions + +@Composable +public fun ThreeCanvasWithControls( + solids: Solids, + builderOfSolid: Deferred, + initialSelected: Name?, + options: Canvas3DOptions?, + tabBuilder: @Composable TabsBuilder.() -> Unit = {}, +) { + var selected: Name? by remember { mutableStateOf(initialSelected) } + var solid: Solid? by remember { mutableStateOf(null) } + + LaunchedEffect(builderOfSolid) { + solids.context.launch { + solid = builderOfSolid.await() + //ensure that the solid is properly rooted + if (solid?.parent == null) { + solid?.setAsRoot(solids.context.visionManager) + } + } + } + + val optionsWithSelector = remember(options) { + (options ?: Canvas3DOptions()).apply { + this.onSelect = { + selected = it + } + } + } + + val selectedVision: Vision? = remember(builderOfSolid, selected) { + selected?.let { + when { + it.isEmpty() -> solid + else -> (solid as? SolidGroup)?.get(it) + } + } + } + + + FlexRow({ + style { + height(100.percent) + width(100.percent) + flexWrap(FlexWrap.Wrap) + alignItems(AlignItems.Stretch) + alignContent(AlignContent.Stretch) + } + }) { + FlexColumn({ + style { + height(100.percent) + minWidth(600.px) + flex(10, 1, 600.px) + position(Position.Relative) + } + }) { + if (solid == null) { + Div({ + style { + position(Position.Fixed) + width(100.percent) + height(100.percent) + zIndex(1000) + top(40.percent) + left(0.px) + opacity(0.5) + filter { + opacity(50.percent) + } + } + }) { + Div({ classes("d-flex", " justify-content-center") }) { + Div({ + classes("spinner-grow", "text-primary") + style { + width(3.cssRem) + height(3.cssRem) + zIndex(20) + } + attr("role", "status") + }) { + Span({ classes("sr-only") }) { Text("Loading 3D vision") } + } + } + } + } else { + ThreeCanvas(solids.context, optionsWithSelector, solid, selected) + } + + selectedVision?.let { vision -> + Div({ + style { + position(Position.Absolute) + top(5.px) + right(5.px) + width(450.px) + } + }) { + Card( + headerAttrs = { + // border = true + }, + header = { + NameCrumbs(selected) { selected = it } + } + ) { + PropertyEditor( + scope = solids.context, + meta = vision.properties.root(), + getPropertyState = { name -> + if (vision.properties.own?.get(name) != null) { + EditorPropertyState.Defined + } else if (vision.properties.root()[name] != null) { + // TODO differentiate + EditorPropertyState.Default() + } else { + EditorPropertyState.Undefined + } + }, + updates = vision.properties.changes, + rootDescriptor = vision.descriptor + ) + + } + + vision.styles.takeIf { it.isNotEmpty() }?.let { styles -> + P { + B { Text("Styles: ") } + Text(styles.joinToString(separator = ", ")) + } + } + } + } + } + } + FlexColumn({ + style { + paddingAll(4.px) + minWidth(400.px) + height(100.percent) + overflowY("auto") + flex(1, 10, 300.px) + } + }) { + ThreeControls(solid, optionsWithSelector, selected, onSelect = { selected = it }, tabBuilder = tabBuilder) + } +} + + diff --git a/ui/compose/src/jsMain/kotlin/space/kscience/visionforge/compose/VisionTree.kt b/ui/compose/src/jsMain/kotlin/space/kscience/visionforge/compose/VisionTree.kt new file mode 100644 index 00000000..2de59f90 --- /dev/null +++ b/ui/compose/src/jsMain/kotlin/space/kscience/visionforge/compose/VisionTree.kt @@ -0,0 +1,96 @@ +package space.kscience.visionforge.compose + +import androidx.compose.runtime.* +import org.jetbrains.compose.web.css.Color +import org.jetbrains.compose.web.css.color +import org.jetbrains.compose.web.css.cursor +import org.jetbrains.compose.web.css.textDecorationLine +import org.jetbrains.compose.web.dom.Div +import org.jetbrains.compose.web.dom.Span +import org.jetbrains.compose.web.dom.Text +import space.kscience.dataforge.names.Name +import space.kscience.dataforge.names.lastOrNull +import space.kscience.dataforge.names.plus +import space.kscience.dataforge.names.startsWith +import space.kscience.visionforge.Vision +import space.kscience.visionforge.VisionGroup +import space.kscience.visionforge.asSequence +import space.kscience.visionforge.compose.TreeStyles.hover +import space.kscience.visionforge.compose.TreeStyles.invoke +import space.kscience.visionforge.isEmpty + + +@Composable +private fun TreeLabel( + vision: Vision, + name: Name, + selected: Name?, + clickCallback: (Name) -> Unit, +) { + Span({ + classes(TreeStyles.treeLabel) + if (name == selected) { + classes(TreeStyles.treeLabelSelected) + } + style { + color(Color("#069")) + cursor("pointer") + hover.invoke { + textDecorationLine("underline") + } + + } + onClick { clickCallback(name) } + }) { + Text(name.lastOrNull()?.toString() ?: "World") + } +} + +@Composable +public fun VisionTree( + vision: Vision, + name: Name = Name.EMPTY, + selected: Name? = null, + clickCallback: (Name) -> Unit, +): Unit { + var expanded: Boolean by remember { mutableStateOf(selected?.startsWith(name) ?: false) } + + //display as node if any child is visible + if (vision is VisionGroup) { + FlexRow { + if (vision.children.keys.any { !it.body.startsWith("@") }) { + Span({ + classes(TreeStyles.treeCaret) + if (expanded) { + classes(TreeStyles.treeCaretDown) + } + onClick { + expanded = !expanded + } + }) + } + TreeLabel(vision, name, selected, clickCallback) + } + if (expanded) { + FlexColumn({ + classes(TreeStyles.tree) + }) { + vision.children.asSequence() + .filter { !it.first.toString().startsWith("@") } // ignore statics and other hidden children + .sortedBy { (it.second as? VisionGroup)?.children?.isEmpty() ?: true } // ignore empty groups + .forEach { (childToken, child) -> + Div({ classes(TreeStyles.treeItem) }) { + VisionTree( + child, + name + childToken, + selected, + clickCallback + ) + } + } + } + } + } else { + TreeLabel(vision, name, selected, clickCallback) + } +} diff --git a/ui/compose/src/jsMain/kotlin/space/kscience/visionforge/compose/bootstrap.kt b/ui/compose/src/jsMain/kotlin/space/kscience/visionforge/compose/bootstrap.kt new file mode 100644 index 00000000..8b0ee5b5 --- /dev/null +++ b/ui/compose/src/jsMain/kotlin/space/kscience/visionforge/compose/bootstrap.kt @@ -0,0 +1,8 @@ +package space.kscience.visionforge.compose + +import androidx.compose.runtime.Composable +import org.jetbrains.compose.web.dom.H5 +import org.jetbrains.compose.web.dom.Text + +@Composable +public fun CardTitle(title: String): Unit = H5({ classes("card-title") }) { Text(title) } \ No newline at end of file diff --git a/ui/compose/src/jsMain/kotlin/space/kscience/visionforge/compose/css.kt b/ui/compose/src/jsMain/kotlin/space/kscience/visionforge/compose/css.kt index 9f6eb3e4..0bf8f7c2 100644 --- a/ui/compose/src/jsMain/kotlin/space/kscience/visionforge/compose/css.kt +++ b/ui/compose/src/jsMain/kotlin/space/kscience/visionforge/compose/css.kt @@ -1,6 +1,7 @@ package space.kscience.visionforge.compose import org.jetbrains.compose.web.css.* +import org.jetbrains.compose.web.css.keywords.CSSAutoKeyword public enum class UserSelect { inherit, initial, revert, revertLayer, unset, @@ -32,4 +33,12 @@ public fun StyleScope.marginAll( left: CSSNumeric = right, ) { margin(top, right, bottom, left) +} + +public fun StyleScope.zIndex(value: Int) { + property("z-index", "$value") +} + +public fun StyleScope.zIndex(value: CSSAutoKeyword) { + property("z-index", value) } \ No newline at end of file