diff --git a/tutorials/Image_And_Icons_Manipulations/README.md b/tutorials/Image_And_Icons_Manipulations/README.md index b5b8d046a0..b34bb292c4 100755 --- a/tutorials/Image_And_Icons_Manipulations/README.md +++ b/tutorials/Image_And_Icons_Manipulations/README.md @@ -19,7 +19,7 @@ import androidx.compose.ui.window.singleWindowApplication fun main() = singleWindowApplication { Image( - painter = painterResource("sample.png"), // ImageBitmap + painter = painterResource("sample.png"), contentDescription = "Sample", modifier = Modifier.fillMaxSize() ) @@ -135,70 +135,66 @@ fun loadXmlImageVector(file: File, density: Density): ImageVector = [XML vector drawable](../../artwork/compose-logo.xml) -## Drawing raw image data using native canvas - -You may want to draw raw image data, in which case you can use `Canvas` and` drawIntoCanvas`. - +## Drawing images using Canvas ```kotlin import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Paint -import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.graphics.withSave +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.loadImageBitmap +import androidx.compose.ui.res.loadSvgPainter +import androidx.compose.ui.res.loadXmlImageVector import androidx.compose.ui.res.useResource import androidx.compose.ui.window.singleWindowApplication -import org.jetbrains.skija.Bitmap -import org.jetbrains.skija.ColorAlphaType -import org.jetbrains.skija.ImageInfo - -private val sample = useResource("sample.png", ::loadImageBitmap) +import org.xml.sax.InputSource fun main() = singleWindowApplication { - val bitmap = remember { bitmapFromByteArray(sample.getBytes(), sample.width, sample.height) } + val density = LocalDensity.current // to calculate the intrinsic size of vector images (SVG, XML) + + val sample = remember { + useResource("sample.png", ::loadImageBitmap) + } + val ideaLogo = remember { + useResource("idea-logo.svg") { loadSvgPainter(it, density) } + } + val composeLogo = rememberVectorPainter( + remember { + useResource("compose-logo.xml") { loadXmlImageVector(InputSource(it), density) } + } + ) + Canvas( modifier = Modifier.fillMaxSize() ) { drawIntoCanvas { canvas -> - canvas.drawImage(bitmap, Offset.Zero, Paint()) + canvas.withSave { + canvas.drawImage(sample, Offset.Zero, Paint()) + canvas.translate(sample.width.toFloat(), 0f) + with(ideaLogo) { + draw(ideaLogo.intrinsicSize) + } + canvas.translate(ideaLogo.intrinsicSize.width, 0f) + with(composeLogo) { + draw(Size(100f, 100f)) + } + } } } } +``` -fun bitmapFromByteArray(pixels: ByteArray, width: Int, height: Int): ImageBitmap { - val bitmap = Bitmap() - bitmap.allocPixels(ImageInfo.makeS32(width, height, ColorAlphaType.PREMUL)) - bitmap.installPixels(bitmap.imageInfo, pixels, (width * 4).toLong()) - return bitmap.asImageBitmap() -} - -// creating byte array from BufferedImage -private fun ImageBitmap.getBytes(): ByteArray { - val buffer = IntArray(width * height) - readPixels(buffer) - - val pixels = ByteArray(width * height * 4) - - var index = 0 - for (y in 0 until height) { - for (x in 0 until width) { - val pixel = buffer[y * width + x] - pixels[index++] = ((pixel and 0xFF)).toByte() // Blue component - pixels[index++] = (((pixel shr 8) and 0xFF)).toByte() // Green component - pixels[index++] = (((pixel shr 16) and 0xFF)).toByte() // Red component - pixels[index++] = (((pixel shr 24) and 0xFF)).toByte() // Alpha component - } - } +[PNG](sample.png) - return pixels -} -``` +[SVG](../../artwork/idea-logo.svg) -Drawing raw images +[XML vector drawable](../../artwork/compose-logo.xml) ## Setting the application window icon @@ -261,4 +257,4 @@ fun main() = application { } ``` -Tray icon \ No newline at end of file +Tray icon diff --git a/tutorials/Image_And_Icons_Manipulations/draw_image_into_canvas.png b/tutorials/Image_And_Icons_Manipulations/draw_image_into_canvas.png deleted file mode 100755 index 370d3a9af2..0000000000 Binary files a/tutorials/Image_And_Icons_Manipulations/draw_image_into_canvas.png and /dev/null differ diff --git a/web/core/build.gradle.kts b/web/core/build.gradle.kts index f4659e50fa..6df6e53958 100644 --- a/web/core/build.gradle.kts +++ b/web/core/build.gradle.kts @@ -30,12 +30,14 @@ kotlin { val jsMain by getting { dependencies { + implementation(project(":internal-web-core-runtime")) implementation(kotlin("stdlib-js")) } } val jsTest by getting { dependencies { + implementation(project(":test-utils")) implementation(kotlin("test-js")) } } @@ -45,5 +47,11 @@ kotlin { implementation(compose.desktop.currentOs) } } + + all { + languageSettings { + useExperimentalAnnotation("org.jetbrains.compose.web.testutils.ComposeWebExperimentalTestsApi") + } + } } } diff --git a/web/core/src/jsMain/kotlin/org/jetbrains/compose/web/RenderComposable.kt b/web/core/src/jsMain/kotlin/org/jetbrains/compose/web/RenderComposable.kt index 9640debd9e..978f7f7031 100644 --- a/web/core/src/jsMain/kotlin/org/jetbrains/compose/web/RenderComposable.kt +++ b/web/core/src/jsMain/kotlin/org/jetbrains/compose/web/RenderComposable.kt @@ -11,6 +11,7 @@ import kotlinx.browser.document import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.launch +import org.jetbrains.compose.web.internal.runtime.* import org.w3c.dom.Element import org.w3c.dom.HTMLBodyElement import org.w3c.dom.get @@ -22,6 +23,7 @@ import org.w3c.dom.get * * @return the instance of the [Composition] */ +@OptIn(ComposeWebInternalApi::class) fun renderComposable( root: TElement, monotonicFrameClock: MonotonicFrameClock = DefaultMonotonicFrameClock, diff --git a/web/core/src/jsMain/kotlin/org/jetbrains/compose/web/attributes/SyntheticEventListener.kt b/web/core/src/jsMain/kotlin/org/jetbrains/compose/web/attributes/SyntheticEventListener.kt index fa7926eaba..66e0f4a482 100644 --- a/web/core/src/jsMain/kotlin/org/jetbrains/compose/web/attributes/SyntheticEventListener.kt +++ b/web/core/src/jsMain/kotlin/org/jetbrains/compose/web/attributes/SyntheticEventListener.kt @@ -9,16 +9,21 @@ import org.jetbrains.compose.web.attributes.EventsListenerBuilder.Companion.CHAN import org.jetbrains.compose.web.attributes.EventsListenerBuilder.Companion.INPUT import org.jetbrains.compose.web.attributes.EventsListenerBuilder.Companion.SELECT import org.jetbrains.compose.web.events.* +import org.jetbrains.compose.web.internal.runtime.ComposeWebInternalApi +import org.jetbrains.compose.web.internal.runtime.NamedEventListener import org.w3c.dom.DragEvent import org.w3c.dom.TouchEvent import org.w3c.dom.clipboard.ClipboardEvent import org.w3c.dom.events.* +@OptIn(ComposeWebInternalApi::class) open class SyntheticEventListener> internal constructor( val event: String, val options: Options, val listener: (T) -> Unit -) : EventListener { +) : EventListener, NamedEventListener { + + override val name: String = event @Suppress("UNCHECKED_CAST") override fun handleEvent(event: Event) { diff --git a/web/core/src/jsMain/kotlin/org/jetbrains/compose/web/css/StyleBuilder.kt b/web/core/src/jsMain/kotlin/org/jetbrains/compose/web/css/StyleBuilder.kt index 4020e79e16..595b078474 100644 --- a/web/core/src/jsMain/kotlin/org/jetbrains/compose/web/css/StyleBuilder.kt +++ b/web/core/src/jsMain/kotlin/org/jetbrains/compose/web/css/StyleBuilder.kt @@ -7,6 +7,9 @@ package org.jetbrains.compose.web.css +import org.jetbrains.compose.web.internal.runtime.DomElementWrapper +import org.jetbrains.compose.web.internal.runtime.ComposeWebInternalApi +import org.w3c.dom.css.CSSStyleDeclaration import kotlin.properties.ReadOnlyProperty /** @@ -116,7 +119,7 @@ fun CSSStyleVariable.value(fallback: TValue? = null) * property("width", AppCSSVariables.width.value()) * } *``` - * + * */ fun variable() = ReadOnlyProperty> { _, property -> @@ -128,6 +131,7 @@ interface StyleHolder { val variables: StylePropertyList } +@OptIn(ComposeWebInternalApi::class) @Suppress("EqualsOrHashCode") open class StyleBuilderImpl : StyleBuilder, StyleHolder { override val properties: MutableStylePropertyList = mutableListOf() diff --git a/web/core/src/jsMain/kotlin/org/jetbrains/compose/web/css/properties/transform.kt b/web/core/src/jsMain/kotlin/org/jetbrains/compose/web/css/properties/transform.kt index 37cdecfe4e..c5e87c67d1 100644 --- a/web/core/src/jsMain/kotlin/org/jetbrains/compose/web/css/properties/transform.kt +++ b/web/core/src/jsMain/kotlin/org/jetbrains/compose/web/css/properties/transform.kt @@ -18,39 +18,39 @@ interface TransformBuilder { a2: Number, b2: Number, c2: Number, d2: Number, a3: Number, b3: Number, c3: Number, d3: Number, a4: Number, b4: Number, c4: Number, d4: Number - ): Boolean - - fun perspective(d: CSSLengthValue): Boolean - fun rotate(a: CSSAngleValue): Boolean - fun rotate3d(x: Number, y: Number, z: Number, a: CSSAngleValue): Boolean - fun rotateX(a: CSSAngleValue): Boolean - fun rotateY(a: CSSAngleValue): Boolean - fun rotateZ(a: CSSAngleValue): Boolean - fun scale(sx: Number): Boolean - fun scale(sx: Number, sy: Number): Boolean - fun scale3d(sx: Number, sy: Number, sz: Number): Boolean - fun scaleX(s: Number): Boolean - fun scaleY(s: Number): Boolean - fun scaleZ(s: Number): Boolean - fun skew(ax: CSSAngleValue): Boolean - fun skew(ax: CSSAngleValue, ay: CSSAngleValue): Boolean - fun skewX(a: CSSAngleValue): Boolean - fun skewY(a: CSSAngleValue): Boolean - fun translate(tx: CSSLengthValue): Boolean - fun translate(tx: CSSPercentageValue): Boolean - fun translate(tx: CSSLengthValue, ty: CSSLengthValue): Boolean - fun translate(tx: CSSLengthValue, ty: CSSPercentageValue): Boolean - fun translate(tx: CSSPercentageValue, ty: CSSLengthValue): Boolean - fun translate(tx: CSSPercentageValue, ty: CSSPercentageValue): Boolean - fun translate3d(tx: CSSLengthValue, ty: CSSLengthValue, tz: CSSLengthValue): Boolean - fun translate3d(tx: CSSLengthValue, ty: CSSPercentageValue, tz: CSSLengthValue): Boolean - fun translate3d(tx: CSSPercentageValue, ty: CSSLengthValue, tz: CSSLengthValue): Boolean - fun translate3d(tx: CSSPercentageValue, ty: CSSPercentageValue, tz: CSSLengthValue): Boolean - fun translateX(tx: CSSLengthValue): Boolean - fun translateX(tx: CSSPercentageValue): Boolean - fun translateY(ty: CSSLengthValue): Boolean - fun translateY(ty: CSSPercentageValue): Boolean - fun translateZ(tz: CSSLengthValue): Boolean + ) + + fun perspective(d: CSSLengthValue) + fun rotate(a: CSSAngleValue) + fun rotate3d(x: Number, y: Number, z: Number, a: CSSAngleValue) + fun rotateX(a: CSSAngleValue) + fun rotateY(a: CSSAngleValue) + fun rotateZ(a: CSSAngleValue) + fun scale(sx: Number) + fun scale(sx: Number, sy: Number) + fun scale3d(sx: Number, sy: Number, sz: Number) + fun scaleX(s: Number) + fun scaleY(s: Number) + fun scaleZ(s: Number) + fun skew(ax: CSSAngleValue) + fun skew(ax: CSSAngleValue, ay: CSSAngleValue) + fun skewX(a: CSSAngleValue) + fun skewY(a: CSSAngleValue) + fun translate(tx: CSSLengthValue) + fun translate(tx: CSSPercentageValue) + fun translate(tx: CSSLengthValue, ty: CSSLengthValue) + fun translate(tx: CSSLengthValue, ty: CSSPercentageValue) + fun translate(tx: CSSPercentageValue, ty: CSSLengthValue) + fun translate(tx: CSSPercentageValue, ty: CSSPercentageValue) + fun translate3d(tx: CSSLengthValue, ty: CSSLengthValue, tz: CSSLengthValue) + fun translate3d(tx: CSSLengthValue, ty: CSSPercentageValue, tz: CSSLengthValue) + fun translate3d(tx: CSSPercentageValue, ty: CSSLengthValue, tz: CSSLengthValue) + fun translate3d(tx: CSSPercentageValue, ty: CSSPercentageValue, tz: CSSLengthValue) + fun translateX(tx: CSSLengthValue) + fun translateX(tx: CSSPercentageValue) + fun translateY(ty: CSSLengthValue) + fun translateY(ty: CSSPercentageValue) + fun translateZ(tz: CSSLengthValue) } private class TransformBuilderImplementation : TransformBuilder { @@ -64,67 +64,133 @@ private class TransformBuilderImplementation : TransformBuilder { a2: Number, b2: Number, c2: Number, d2: Number, a3: Number, b3: Number, c3: Number, d3: Number, a4: Number, b4: Number, c4: Number, d4: Number - ) = + ) { transformations.add { "matrix3d($a1, $b1, $c1, $d1, $a2, $b2, $c2, $d2, $a3, $b3, $c3, $d3, $a4, $b4, $c4, $d4)" } + } + + override fun perspective(d: CSSLengthValue) { + transformations.add { "perspective($d)" } + } - override fun perspective(d: CSSLengthValue) = transformations.add { "perspective($d)" } + override fun rotate(a: CSSAngleValue) { + transformations.add { "rotate($a)" } + } - override fun rotate(a: CSSAngleValue) = transformations.add { "rotate($a)" } - override fun rotate3d(x: Number, y: Number, z: Number, a: CSSAngleValue) = + override fun rotate3d(x: Number, y: Number, z: Number, a: CSSAngleValue) { transformations.add({ "rotate3d($x, $y, $z, $a)" }) + } + + override fun rotateX(a: CSSAngleValue) { + transformations.add { "rotateX($a)" } + } + + override fun rotateY(a: CSSAngleValue) { + transformations.add { "rotateY($a)" } + } - override fun rotateX(a: CSSAngleValue) = transformations.add { "rotateX($a)" } - override fun rotateY(a: CSSAngleValue) = transformations.add { "rotateY($a)" } - override fun rotateZ(a: CSSAngleValue) = transformations.add { "rotateZ($a)" } + override fun rotateZ(a: CSSAngleValue) { + transformations.add { "rotateZ($a)" } + } + + override fun scale(sx: Number) { + transformations.add { "scale($sx)" } + } - override fun scale(sx: Number) = transformations.add { "scale($sx)" } - override fun scale(sx: Number, sy: Number) = transformations.add { "scale($sx, $sy)" } - override fun scale3d(sx: Number, sy: Number, sz: Number) = + override fun scale(sx: Number, sy: Number) { + transformations.add { "scale($sx, $sy)" } + } + + override fun scale3d(sx: Number, sy: Number, sz: Number) { transformations.add { "scale3d($sx, $sy, $sz)" } + } - override fun scaleX(s: Number) = transformations.add { "scaleX($s)" } - override fun scaleY(s: Number) = transformations.add { "scaleY($s)" } - override fun scaleZ(s: Number) = transformations.add { "scaleZ($s)" } + override fun scaleX(s: Number) { + transformations.add { "scaleX($s)" } + } - override fun skew(ax: CSSAngleValue) = transformations.add { "skew($ax)" } - override fun skew(ax: CSSAngleValue, ay: CSSAngleValue) = transformations.add { "skew($ax, $ay)" } - override fun skewX(a: CSSAngleValue) = transformations.add { "skewX($a)" } - override fun skewY(a: CSSAngleValue) = transformations.add { "skewY($a)" } + override fun scaleY(s: Number) { + transformations.add { "scaleY($s)" } + } - override fun translate(tx: CSSLengthValue) = transformations.add { "translate($tx)" } - override fun translate(tx: CSSPercentageValue) = transformations.add { "translate($tx)" } + override fun scaleZ(s: Number) { + transformations.add { "scaleZ($s)" } + } - override fun translate(tx: CSSLengthValue, ty: CSSLengthValue) = + override fun skew(ax: CSSAngleValue) { + transformations.add { "skew($ax)" } + } + + override fun skew(ax: CSSAngleValue, ay: CSSAngleValue) { + transformations.add { "skew($ax, $ay)" } + } + + override fun skewX(a: CSSAngleValue) { + transformations.add { "skewX($a)" } + } + + override fun skewY(a: CSSAngleValue) { + transformations.add { "skewY($a)" } + } + + override fun translate(tx: CSSLengthValue) { + transformations.add { "translate($tx)" } + } + + override fun translate(tx: CSSPercentageValue) { + transformations.add { "translate($tx)" } + } + + override fun translate(tx: CSSLengthValue, ty: CSSLengthValue) { transformations.add { "translate($tx, $ty)" } + } - override fun translate(tx: CSSLengthValue, ty: CSSPercentageValue) = + override fun translate(tx: CSSLengthValue, ty: CSSPercentageValue) { transformations.add { "translate($tx, $ty)" } + } - override fun translate(tx: CSSPercentageValue, ty: CSSLengthValue) = + override fun translate(tx: CSSPercentageValue, ty: CSSLengthValue) { transformations.add { "translate($tx, $ty)" } + } - override fun translate(tx: CSSPercentageValue, ty: CSSPercentageValue) = + override fun translate(tx: CSSPercentageValue, ty: CSSPercentageValue) { transformations.add { "translate($tx, $ty)" } + } - override fun translate3d(tx: CSSLengthValue, ty: CSSLengthValue, tz: CSSLengthValue) = + override fun translate3d(tx: CSSLengthValue, ty: CSSLengthValue, tz: CSSLengthValue) { transformations.add { "translate3d($tx, $ty, $tz)" } + } - override fun translate3d(tx: CSSLengthValue, ty: CSSPercentageValue, tz: CSSLengthValue) = + override fun translate3d(tx: CSSLengthValue, ty: CSSPercentageValue, tz: CSSLengthValue) { transformations.add { "translate3d($tx, $ty, $tz)" } + } - override fun translate3d(tx: CSSPercentageValue, ty: CSSLengthValue, tz: CSSLengthValue) = + override fun translate3d(tx: CSSPercentageValue, ty: CSSLengthValue, tz: CSSLengthValue) { transformations.add { "translate3d($tx, $ty, $tz)" } + } - override fun translate3d(tx: CSSPercentageValue, ty: CSSPercentageValue, tz: CSSLengthValue) = + override fun translate3d(tx: CSSPercentageValue, ty: CSSPercentageValue, tz: CSSLengthValue) { transformations.add { "translate3d($tx, $ty, $tz)" } + } - override fun translateX(tx: CSSLengthValue) = transformations.add { "translateX($tx)" } - override fun translateX(tx: CSSPercentageValue) = transformations.add { "translateX($tx)" } + override fun translateX(tx: CSSLengthValue) { + transformations.add { "translateX($tx)" } + } - override fun translateY(ty: CSSLengthValue) = transformations.add { "translateY($ty)" } - override fun translateY(ty: CSSPercentageValue) = transformations.add { "translateY($ty)" } + override fun translateX(tx: CSSPercentageValue) { + transformations.add { "translateX($tx)" } + } - override fun translateZ(tz: CSSLengthValue) = transformations.add { "translateZ($tz)" } + override fun translateY(ty: CSSLengthValue) { + transformations.add { "translateY($ty)" } + } + + override fun translateY(ty: CSSPercentageValue) { + transformations.add { "translateY($ty)" } + } + + override fun translateZ(tz: CSSLengthValue) { + transformations.add { "translateZ($tz)" } + } override fun toString(): String { return transformations.joinToString(" ") { it.apply() } @@ -132,7 +198,7 @@ private class TransformBuilderImplementation : TransformBuilder { } @ExperimentalComposeWebApi -fun StyleBuilder.transform(transformFunction: TransformBuilder.() -> Unit) { +fun StyleBuilder.transform(transformContext: TransformBuilder.() -> Unit) { val transformBuilder = TransformBuilderImplementation() - property("transform", transformBuilder.apply(transformFunction).toString()) + property("transform", transformBuilder.apply(transformContext).toString()) } \ No newline at end of file diff --git a/web/core/src/jsMain/kotlin/org/jetbrains/compose/web/elements/Base.kt b/web/core/src/jsMain/kotlin/org/jetbrains/compose/web/elements/Base.kt index 26ce1ca8b6..b00eef957e 100644 --- a/web/core/src/jsMain/kotlin/org/jetbrains/compose/web/elements/Base.kt +++ b/web/core/src/jsMain/kotlin/org/jetbrains/compose/web/elements/Base.kt @@ -1,32 +1,25 @@ package org.jetbrains.compose.web.dom -import androidx.compose.runtime.Applier -import androidx.compose.runtime.Composable -import androidx.compose.runtime.ComposeCompilerApi -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.DisposableEffectResult -import androidx.compose.runtime.DisposableEffectScope -import androidx.compose.runtime.ExplicitGroupsComposable -import androidx.compose.runtime.SkippableUpdater -import androidx.compose.runtime.currentComposer -import androidx.compose.runtime.remember -import org.jetbrains.compose.web.DomApplier -import org.jetbrains.compose.web.DomElementWrapper +import androidx.compose.runtime.* import org.jetbrains.compose.web.attributes.AttrsBuilder import org.jetbrains.compose.web.ExperimentalComposeWebApi +import org.jetbrains.compose.web.css.StyleHolder +import org.jetbrains.compose.web.internal.runtime.DomElementWrapper +import org.jetbrains.compose.web.internal.runtime.ComposeWebInternalApi import org.w3c.dom.Element import org.w3c.dom.HTMLElement +import org.w3c.dom.css.ElementCSSInlineStyle +import org.w3c.dom.svg.SVGElement @OptIn(ComposeCompilerApi::class) @Composable @ExplicitGroupsComposable -inline fun > ComposeDomNode( +private inline fun ComposeDomNode( noinline factory: () -> T, elementScope: TScope, noinline attrsSkippableUpdate: @Composable SkippableUpdater.() -> Unit, noinline content: (@Composable TScope.() -> Unit)? ) { - if (currentComposer.applier !is E) error("Invalid applier") currentComposer.startNode() if (currentComposer.inserting) { currentComposer.createNode(factory) @@ -34,9 +27,7 @@ inline fun > ComposeDomNode( currentComposer.useNode() } - SkippableUpdater(currentComposer).apply { - attrsSkippableUpdate() - } + attrsSkippableUpdate.invoke(SkippableUpdater(currentComposer)) currentComposer.startReplaceableGroup(0x7ab4aae9) content?.invoke(elementScope) @@ -44,48 +35,85 @@ inline fun > ComposeDomNode( currentComposer.endNode() } -class DisposableEffectHolder( - var effect: (DisposableEffectScope.(TElement) -> DisposableEffectResult)? = null -) +@OptIn(ComposeWebInternalApi::class) +private fun DomElementWrapper.updateProperties(applicators: List Unit, Any>>) { + node.removeAttribute("class") + + applicators.forEach { (applicator, item) -> + applicator(node, item) + } +} + +@OptIn(ComposeWebInternalApi::class) +private fun DomElementWrapper.updateStyleDeclarations(styleApplier: StyleHolder) { + when (node) { + is HTMLElement, is SVGElement -> { + node.removeAttribute("style") + + val style = node.unsafeCast().style + + styleApplier.properties.forEach { (name, value) -> + style.setProperty(name, value.toString()) + } + + styleApplier.variables.forEach { (name, value) -> + style.setProperty(name, value.toString()) + } + } + } +} + +@OptIn(ComposeWebInternalApi::class) +fun DomElementWrapper.updateAttrs(attrs: Map) { + node.getAttributeNames().forEach { name -> + if (name == "style") return@forEach + node.removeAttribute(name) + } + + attrs.forEach { + node.setAttribute(it.key, it.value) + } +} + + +@OptIn(ComposeWebInternalApi::class) @Composable fun TagElement( elementBuilder: ElementBuilder, applyAttrs: (AttrsBuilder.() -> Unit)?, content: (@Composable ElementScope.() -> Unit)? ) { - val scope = remember { ElementScopeImpl() } - val refEffect = remember { DisposableEffectHolder() } + val scope = remember { ElementScopeImpl() } + var refEffect: (DisposableEffectScope.(TElement) -> DisposableEffectResult)? = null - ComposeDomNode, DomElementWrapper, DomApplier>( + ComposeDomNode, DomElementWrapper>( factory = { - DomElementWrapper(elementBuilder.create() as HTMLElement).also { - scope.element = it.node.unsafeCast() - } + val node = elementBuilder.create() + scope.element = node + DomElementWrapper(node) }, attrsSkippableUpdate = { - val attrsApplied = AttrsBuilder().also { - if (applyAttrs != null) { - it.applyAttrs() - } - } - refEffect.effect = attrsApplied.refEffect - val attrsCollected = attrsApplied.collect() - val events = attrsApplied.collectListeners() + val attrsBuilder = AttrsBuilder() + applyAttrs?.invoke(attrsBuilder) + + refEffect = attrsBuilder.refEffect update { - set(attrsCollected, DomElementWrapper::updateAttrs) - set(events, DomElementWrapper::updateEventListeners) - set(attrsApplied.propertyUpdates, DomElementWrapper::updateProperties) - set(attrsApplied.styleBuilder, DomElementWrapper::updateStyleDeclarations) + set(attrsBuilder.collect(), DomElementWrapper::updateAttrs) + set(attrsBuilder.collectListeners(), DomElementWrapper::updateEventListeners) + set(attrsBuilder.propertyUpdates, DomElementWrapper::updateProperties) + set(attrsBuilder.styleBuilder, DomElementWrapper::updateStyleDeclarations) } }, elementScope = scope, content = content ) - DisposableEffect(null) { - refEffect.effect?.invoke(this, scope.element) ?: onDispose {} + refEffect?.let { effect -> + DisposableEffect(null) { + effect.invoke(this, scope.element) + } } } diff --git a/web/core/src/jsMain/kotlin/org/jetbrains/compose/web/elements/Elements.kt b/web/core/src/jsMain/kotlin/org/jetbrains/compose/web/elements/Elements.kt index 644ae0f79e..afab3a5d8c 100644 --- a/web/core/src/jsMain/kotlin/org/jetbrains/compose/web/elements/Elements.kt +++ b/web/core/src/jsMain/kotlin/org/jetbrains/compose/web/elements/Elements.kt @@ -4,14 +4,15 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.ComposeNode import androidx.compose.runtime.remember import androidx.compose.web.attributes.SelectAttrsBuilder -import org.jetbrains.compose.web.DomApplier -import org.jetbrains.compose.web.DomNodeWrapper import kotlinx.browser.document import org.jetbrains.compose.web.attributes.* import org.jetbrains.compose.web.attributes.builders.* import org.jetbrains.compose.web.css.CSSRuleDeclarationList import org.jetbrains.compose.web.css.StyleSheetBuilder import org.jetbrains.compose.web.css.StyleSheetBuilderImpl +import org.jetbrains.compose.web.internal.runtime.DomApplier +import org.jetbrains.compose.web.internal.runtime.DomNodeWrapper +import org.jetbrains.compose.web.internal.runtime.ComposeWebInternalApi import org.w3c.dom.Element import org.w3c.dom.HTMLAnchorElement import org.w3c.dom.HTMLAreaElement @@ -414,6 +415,7 @@ fun Source( ) } +@OptIn(ComposeWebInternalApi::class) @Composable fun Text(value: String) { ComposeNode( diff --git a/web/core/src/jsTest/kotlin/CSSStylesheetTests.kt b/web/core/src/jsTest/kotlin/CSSStylesheetTests.kt index 8874c35d08..4fabc6189f 100644 --- a/web/core/src/jsTest/kotlin/CSSStylesheetTests.kt +++ b/web/core/src/jsTest/kotlin/CSSStylesheetTests.kt @@ -12,6 +12,7 @@ import org.w3c.dom.HTMLElement import org.w3c.dom.get import kotlin.test.Test import kotlin.test.assertEquals +import org.jetbrains.compose.web.testutils.* object AppCSSVariables { val width by variable() @@ -143,4 +144,4 @@ class CSSVariableTests { assertEquals("rgb(0, 128, 0)", window.getComputedStyle(el).backgroundColor) } } -} \ No newline at end of file +} diff --git a/web/core/src/jsTest/kotlin/CSSUnitApiTests.kt b/web/core/src/jsTest/kotlin/CSSUnitApiTests.kt index 20e2788a47..d80dd26290 100644 --- a/web/core/src/jsTest/kotlin/CSSUnitApiTests.kt +++ b/web/core/src/jsTest/kotlin/CSSUnitApiTests.kt @@ -11,6 +11,7 @@ import org.w3c.dom.HTMLElement import org.w3c.dom.get import kotlin.test.Test import kotlin.test.assertEquals +import org.jetbrains.compose.web.testutils.* class CSSUnitApiTests { // TODO: Cover CSS.Q, CSS.khz and CSS.hz after we'll get rid from polyfill @@ -525,4 +526,4 @@ class CSSUnitApiTests { assertEquals("5px", (root.children[0] as HTMLElement).style.left) assertEquals("8px", (root.children[0] as HTMLElement).style.top) } -} \ No newline at end of file +} diff --git a/web/core/src/jsTest/kotlin/ControlledRadioGroupsTests.kt b/web/core/src/jsTest/kotlin/ControlledRadioGroupsTests.kt index 9597b6f72d..9bf138d794 100644 --- a/web/core/src/jsTest/kotlin/ControlledRadioGroupsTests.kt +++ b/web/core/src/jsTest/kotlin/ControlledRadioGroupsTests.kt @@ -8,6 +8,7 @@ import org.jetbrains.compose.web.dom.RadioInput import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue +import org.jetbrains.compose.web.testutils.* class ControlledRadioGroupsTests { diff --git a/web/core/src/jsTest/kotlin/DomSideEffectTests.kt b/web/core/src/jsTest/kotlin/DomSideEffectTests.kt index d39f3ede06..5c2b69bbfb 100644 --- a/web/core/src/jsTest/kotlin/DomSideEffectTests.kt +++ b/web/core/src/jsTest/kotlin/DomSideEffectTests.kt @@ -5,6 +5,7 @@ import org.jetbrains.compose.web.dom.Div import org.jetbrains.compose.web.renderComposable import kotlinx.browser.document import kotlinx.dom.clear +import org.jetbrains.compose.web.testutils.* import kotlin.test.Test import kotlin.test.assertEquals diff --git a/web/core/src/jsTest/kotlin/FailingTestCases.kt b/web/core/src/jsTest/kotlin/FailingTestCases.kt index a23fc3b022..549da12b15 100644 --- a/web/core/src/jsTest/kotlin/FailingTestCases.kt +++ b/web/core/src/jsTest/kotlin/FailingTestCases.kt @@ -5,9 +5,7 @@ package org.jetbrains.compose.web.core.tests -import androidx.compose.runtime.Composable import kotlin.test.Test -import kotlin.test.assertEquals import kotlin.test.assertTrue class FailingTestCases { diff --git a/web/core/src/jsTest/kotlin/InlineStyleTests.kt b/web/core/src/jsTest/kotlin/InlineStyleTests.kt index deb1107ab4..7a283bf4bb 100644 --- a/web/core/src/jsTest/kotlin/InlineStyleTests.kt +++ b/web/core/src/jsTest/kotlin/InlineStyleTests.kt @@ -9,6 +9,7 @@ import org.jetbrains.compose.web.dom.Span import org.jetbrains.compose.web.dom.Text import kotlin.test.Test import kotlin.test.assertEquals +import org.jetbrains.compose.web.testutils.* class InlineStyleTests { diff --git a/web/core/src/jsTest/kotlin/StaticComposableTests.kt b/web/core/src/jsTest/kotlin/StaticComposableTests.kt index 2b1b7785e4..1b5dc868ee 100644 --- a/web/core/src/jsTest/kotlin/StaticComposableTests.kt +++ b/web/core/src/jsTest/kotlin/StaticComposableTests.kt @@ -10,6 +10,7 @@ import org.w3c.dom.get import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue +import org.jetbrains.compose.web.testutils.* class StaticComposableTests { @Test @@ -184,12 +185,10 @@ class StaticComposableTests { } @Test - fun stylesPosition() { - val root = "div".asHtmlElement() + fun stylesPosition() = runTest { val enumValues = Position.values() - renderComposable( - root = root - ) { + + composition { enumValues.forEach { position -> Span( { @@ -197,14 +196,14 @@ class StaticComposableTests { position(position) } } - ) { } + ) } } enumValues.forEachIndexed { index, position -> assertEquals( "position: ${position.value};", - (root.children[index] as HTMLElement).style.cssText + nextChild().style.cssText ) } } diff --git a/web/core/src/jsTest/kotlin/css/CSSBackgroundTests.kt b/web/core/src/jsTest/kotlin/css/CSSBackgroundTests.kt index 709fea5e50..d989249ed4 100644 --- a/web/core/src/jsTest/kotlin/css/CSSBackgroundTests.kt +++ b/web/core/src/jsTest/kotlin/css/CSSBackgroundTests.kt @@ -6,13 +6,11 @@ package org.jetbrains.compose.web.core.tests.css import kotlinx.browser.window -import org.jetbrains.compose.web.core.tests.runTest import org.jetbrains.compose.web.css.* import org.jetbrains.compose.web.dom.Div -import org.w3c.dom.HTMLElement -import org.w3c.dom.get import kotlin.test.Test import kotlin.test.assertEquals +import org.jetbrains.compose.web.testutils.* class CSSBackgroundTests { @Test @@ -187,4 +185,4 @@ class CSSBackgroundTests { assertEquals("no-repeat", window.getComputedStyle(nextChild()).backgroundRepeat) } -} \ No newline at end of file +} diff --git a/web/core/src/jsTest/kotlin/css/CSSBorderTests.kt b/web/core/src/jsTest/kotlin/css/CSSBorderTests.kt index 576a118524..319a717ee1 100644 --- a/web/core/src/jsTest/kotlin/css/CSSBorderTests.kt +++ b/web/core/src/jsTest/kotlin/css/CSSBorderTests.kt @@ -5,7 +5,7 @@ package org.jetbrains.compose.web.core.tests.css -import org.jetbrains.compose.web.core.tests.runTest +import org.jetbrains.compose.web.testutils.* import org.jetbrains.compose.web.css.* import org.jetbrains.compose.web.dom.Div import kotlin.test.Test @@ -66,4 +66,4 @@ class CSSBorderTests { assertEquals("3px 5px 4px", (nextChild()).style.borderWidth) assertEquals("3px 5px 4px 2px", (nextChild()).style.borderWidth) } -} \ No newline at end of file +} diff --git a/web/core/src/jsTest/kotlin/css/CSSBoxTests.kt b/web/core/src/jsTest/kotlin/css/CSSBoxTests.kt index dc2725e265..eb26c1ad35 100644 --- a/web/core/src/jsTest/kotlin/css/CSSBoxTests.kt +++ b/web/core/src/jsTest/kotlin/css/CSSBoxTests.kt @@ -5,11 +5,9 @@ package org.jetbrains.compose.web.core.tests.css -import org.jetbrains.compose.web.core.tests.asHtmlElement -import org.jetbrains.compose.web.core.tests.runTest +import org.jetbrains.compose.web.testutils.* import org.jetbrains.compose.web.css.* import org.jetbrains.compose.web.dom.Div -import org.jetbrains.compose.web.renderComposable import org.w3c.dom.HTMLElement import org.w3c.dom.get import kotlin.test.Test @@ -266,4 +264,4 @@ class CSSBoxTests { assertEquals("max-content", (nextChild()).style.maxHeight) assertEquals("min-content", (nextChild()).style.maxHeight) } -} \ No newline at end of file +} diff --git a/web/core/src/jsTest/kotlin/css/CSSDisplayTests.kt b/web/core/src/jsTest/kotlin/css/CSSDisplayTests.kt index 3633eda658..db8bc3f7ea 100644 --- a/web/core/src/jsTest/kotlin/css/CSSDisplayTests.kt +++ b/web/core/src/jsTest/kotlin/css/CSSDisplayTests.kt @@ -5,14 +5,12 @@ package org.jetbrains.compose.web.core.tests.css -import org.jetbrains.compose.web.core.tests.runTest +import org.jetbrains.compose.web.testutils.* import org.jetbrains.compose.web.core.tests.values import org.jetbrains.compose.web.css.DisplayStyle import org.jetbrains.compose.web.css.display import org.jetbrains.compose.web.css.value import org.jetbrains.compose.web.dom.Div -import org.w3c.dom.HTMLElement -import org.w3c.dom.get import kotlin.test.Test import kotlin.test.assertEquals @@ -42,4 +40,4 @@ class CSSDisplayTests { } } -} \ No newline at end of file +} diff --git a/web/core/src/jsTest/kotlin/css/CSSFlexTests.kt b/web/core/src/jsTest/kotlin/css/CSSFlexTests.kt index aabdd2130f..84d1dbfd2a 100644 --- a/web/core/src/jsTest/kotlin/css/CSSFlexTests.kt +++ b/web/core/src/jsTest/kotlin/css/CSSFlexTests.kt @@ -5,7 +5,7 @@ package org.jetbrains.compose.web.core.tests.css -import org.jetbrains.compose.web.core.tests.runTest +import org.jetbrains.compose.web.testutils.* import org.jetbrains.compose.web.core.tests.values import org.jetbrains.compose.web.css.* import org.jetbrains.compose.web.dom.Div @@ -378,4 +378,4 @@ class CSSFlexTests { } } -} \ No newline at end of file +} diff --git a/web/core/src/jsTest/kotlin/css/CSSListStyleTests.kt b/web/core/src/jsTest/kotlin/css/CSSListStyleTests.kt index 27d6e043d7..22c2464070 100644 --- a/web/core/src/jsTest/kotlin/css/CSSListStyleTests.kt +++ b/web/core/src/jsTest/kotlin/css/CSSListStyleTests.kt @@ -5,7 +5,7 @@ package org.jetbrains.compose.web.core.tests.css -import org.jetbrains.compose.web.core.tests.runTest +import org.jetbrains.compose.web.testutils.* import org.jetbrains.compose.web.css.* import org.jetbrains.compose.web.dom.Div import org.w3c.dom.HTMLElement @@ -105,4 +105,4 @@ class CSSListStyleTests { assertEquals("georgian", (currentChild()).style.listStyleType) } -} \ No newline at end of file +} diff --git a/web/core/src/jsTest/kotlin/css/CSSMarginTests.kt b/web/core/src/jsTest/kotlin/css/CSSMarginTests.kt index 21922d78e1..364c41a0a1 100644 --- a/web/core/src/jsTest/kotlin/css/CSSMarginTests.kt +++ b/web/core/src/jsTest/kotlin/css/CSSMarginTests.kt @@ -6,11 +6,9 @@ package org.jetbrains.compose.web.core.tests.css import kotlinx.browser.window -import org.jetbrains.compose.web.core.tests.runTest +import org.jetbrains.compose.web.testutils.* import org.jetbrains.compose.web.css.* import org.jetbrains.compose.web.dom.Div -import org.w3c.dom.HTMLElement -import org.w3c.dom.get import kotlin.test.Test import kotlin.test.assertEquals @@ -139,4 +137,4 @@ class CSSMarginTests { assertEquals("1px", el.marginLeft, "marginLeft") } -} \ No newline at end of file +} diff --git a/web/core/src/jsTest/kotlin/css/CSSOverflowTests.kt b/web/core/src/jsTest/kotlin/css/CSSOverflowTests.kt index 80157345e7..113567cbb9 100644 --- a/web/core/src/jsTest/kotlin/css/CSSOverflowTests.kt +++ b/web/core/src/jsTest/kotlin/css/CSSOverflowTests.kt @@ -5,11 +5,9 @@ package org.jetbrains.compose.web.core.tests.css -import org.jetbrains.compose.web.core.tests.runTest +import org.jetbrains.compose.web.testutils.* import org.jetbrains.compose.web.css.* import org.jetbrains.compose.web.dom.Div -import org.w3c.dom.HTMLElement -import org.w3c.dom.get import kotlin.test.Test import kotlin.test.assertEquals @@ -104,4 +102,4 @@ class CSSOverflowTests { } -} \ No newline at end of file +} diff --git a/web/core/src/jsTest/kotlin/css/CSSPaddingTests.kt b/web/core/src/jsTest/kotlin/css/CSSPaddingTests.kt index a86c12a0c1..c94a6eed8d 100644 --- a/web/core/src/jsTest/kotlin/css/CSSPaddingTests.kt +++ b/web/core/src/jsTest/kotlin/css/CSSPaddingTests.kt @@ -6,11 +6,9 @@ package org.jetbrains.compose.web.core.tests.css import kotlinx.browser.window -import org.jetbrains.compose.web.core.tests.runTest +import org.jetbrains.compose.web.testutils.* import org.jetbrains.compose.web.css.* import org.jetbrains.compose.web.dom.Div -import org.w3c.dom.HTMLElement -import org.w3c.dom.get import kotlin.test.Test import kotlin.test.assertEquals @@ -139,4 +137,4 @@ class CSSPaddingTests { assertEquals("1px", el.paddingLeft, "paddingLeft") } -} \ No newline at end of file +} diff --git a/web/core/src/jsTest/kotlin/css/CSSTextTests.kt b/web/core/src/jsTest/kotlin/css/CSSTextTests.kt index efae748b17..91f241470b 100644 --- a/web/core/src/jsTest/kotlin/css/CSSTextTests.kt +++ b/web/core/src/jsTest/kotlin/css/CSSTextTests.kt @@ -5,12 +5,9 @@ package org.jetbrains.compose.web.core.tests.css -import kotlinx.browser.window -import org.jetbrains.compose.web.core.tests.runTest +import org.jetbrains.compose.web.testutils.* import org.jetbrains.compose.web.css.* import org.jetbrains.compose.web.dom.Div -import org.w3c.dom.HTMLElement -import org.w3c.dom.get import kotlin.test.Test import kotlin.test.assertEquals @@ -397,4 +394,4 @@ class CSSTextTests { assertEquals("break-spaces", (nextChild()).style.whiteSpace) } -} \ No newline at end of file +} diff --git a/web/core/src/jsTest/kotlin/css/CSSUiTests.kt b/web/core/src/jsTest/kotlin/css/CSSUiTests.kt index dcde84b40b..4b38f1ef1c 100644 --- a/web/core/src/jsTest/kotlin/css/CSSUiTests.kt +++ b/web/core/src/jsTest/kotlin/css/CSSUiTests.kt @@ -5,11 +5,9 @@ package org.jetbrains.compose.web.core.tests.css -import org.jetbrains.compose.web.core.tests.runTest +import org.jetbrains.compose.web.testutils.* import org.jetbrains.compose.web.css.cursor import org.jetbrains.compose.web.dom.Div -import org.w3c.dom.HTMLElement -import org.w3c.dom.get import kotlin.test.Test import kotlin.test.assertEquals @@ -44,4 +42,4 @@ class CSSUiTests { assertEquals("url(\"hand.cur\"), pointer", (nextChild()).style.cursor) assertEquals("url(\"cursor2.png\") 2 2, pointer", (nextChild()).style.cursor) } -} \ No newline at end of file +} diff --git a/web/core/src/jsTest/kotlin/css/ColorTests.kt b/web/core/src/jsTest/kotlin/css/ColorTests.kt index 09635f149d..f57581150b 100644 --- a/web/core/src/jsTest/kotlin/css/ColorTests.kt +++ b/web/core/src/jsTest/kotlin/css/ColorTests.kt @@ -5,7 +5,7 @@ package org.jetbrains.compose.web.core.tests.css -import org.jetbrains.compose.web.core.tests.runTest +import org.jetbrains.compose.web.testutils.* import org.jetbrains.compose.web.css.* import org.jetbrains.compose.web.dom.Div import org.w3c.dom.HTMLElement diff --git a/web/core/src/jsTest/kotlin/css/FilterTests.kt b/web/core/src/jsTest/kotlin/css/FilterTests.kt index 4a2f1a98aa..89dae0b3f4 100644 --- a/web/core/src/jsTest/kotlin/css/FilterTests.kt +++ b/web/core/src/jsTest/kotlin/css/FilterTests.kt @@ -6,7 +6,7 @@ package org.jetbrains.compose.web.core.tests.css import org.jetbrains.compose.web.ExperimentalComposeWebApi -import org.jetbrains.compose.web.core.tests.runTest +import org.jetbrains.compose.web.testutils.* import org.jetbrains.compose.web.css.* import org.jetbrains.compose.web.dom.Div import org.jetbrains.compose.web.dom.Img @@ -127,4 +127,4 @@ class FilterTests { assertEquals("drop-shadow(black 16px 16px 10px)", nextChild().style.filter) } -} \ No newline at end of file +} diff --git a/web/core/src/jsTest/kotlin/css/GridTests.kt b/web/core/src/jsTest/kotlin/css/GridTests.kt index fa1340985c..48bae5779c 100644 --- a/web/core/src/jsTest/kotlin/css/GridTests.kt +++ b/web/core/src/jsTest/kotlin/css/GridTests.kt @@ -5,11 +5,9 @@ package org.jetbrains.compose.web.core.tests.css -import androidx.compose.runtime.compositionLocalOf -import org.jetbrains.compose.web.core.tests.runTest +import org.jetbrains.compose.web.testutils.* import org.jetbrains.compose.web.css.* import org.jetbrains.compose.web.dom.Div -import kotlin.js.Json import kotlin.test.Test import kotlin.test.assertEquals @@ -648,4 +646,4 @@ class GridAutoFlowTests { assertEquals("dense", nextChild().style.asDynamic().gridAutoFlow) assertEquals("row", nextChild().style.asDynamic().gridAutoFlow) } -} \ No newline at end of file +} diff --git a/web/core/src/jsTest/kotlin/css/TransformTests.kt b/web/core/src/jsTest/kotlin/css/TransformTests.kt index ce671d8181..b2a0d0a609 100644 --- a/web/core/src/jsTest/kotlin/css/TransformTests.kt +++ b/web/core/src/jsTest/kotlin/css/TransformTests.kt @@ -5,7 +5,7 @@ package org.jetbrains.compose.web.core.tests.css import org.jetbrains.compose.web.ExperimentalComposeWebApi -import org.jetbrains.compose.web.core.tests.runTest +import org.jetbrains.compose.web.testutils.* import org.jetbrains.compose.web.css.* import org.jetbrains.compose.web.dom.Div import kotlin.test.Test @@ -255,4 +255,4 @@ class TransformTests { assertEquals("perspective(3cm) translate(10px, 3px) rotateY(3deg)", nextChild().style.transform) } -} \ No newline at end of file +} diff --git a/web/core/src/jsTest/kotlin/elements/AttributesTests.kt b/web/core/src/jsTest/kotlin/elements/AttributesTests.kt index 15bb55a7e1..ce55090ac2 100644 --- a/web/core/src/jsTest/kotlin/elements/AttributesTests.kt +++ b/web/core/src/jsTest/kotlin/elements/AttributesTests.kt @@ -3,6 +3,8 @@ package org.jetbrains.compose.web.core.tests import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.setValue +import kotlinx.browser.document +import kotlinx.dom.clear import org.jetbrains.compose.web.attributes.AttrsBuilder import org.jetbrains.compose.web.attributes.disabled import org.jetbrains.compose.web.attributes.forId @@ -17,6 +19,7 @@ import org.w3c.dom.HTMLDivElement import org.w3c.dom.HTMLElement import kotlin.test.Test import kotlin.test.assertEquals +import org.jetbrains.compose.web.testutils.* class AttributesTests { @@ -333,26 +336,72 @@ class AttributesTests { assertEquals(0, refDisposeCounter) } + @Test + fun disposableRefEffectWithChangingKey() = runTest { + var key by mutableStateOf(0) + + composition { + val readKey = key // read key here to recompose an entire scope + Div( + attrs = { + id("id$readKey") + } + ) { + DisposableRefEffect(readKey) { + val p = document.createElement("p").also { it.innerHTML = "Key=$readKey" } + it.appendChild(p) + + onDispose { + it.clear() + } + } + } + } + + assertEquals( + expected = "

Key=0

", + actual = root.outerHTML + ) + + key = 1 + waitForRecompositionComplete() + + assertEquals( + expected = "

Key=1

", + actual = root.outerHTML + ) + } + @Test // issue: https://github.com/JetBrains/compose-jb/issues/981 fun attributesUpdateShouldNotCauseInlineStylesCleanUp() = runTest { var hasValue by mutableStateOf(false) composition { Button(attrs = { + classes("a") style { color(Color.red) } - if (hasValue) value("buttonValue") + if (hasValue) { + classes("b") + value("buttonValue") + } }) { Text("Button") } } - assertEquals("""""", root.innerHTML) + assertEquals( + expected = "", + actual = nextChild().outerHTML + ) hasValue = true waitForRecompositionComplete() - assertEquals("""""", root.innerHTML) + assertEquals( + expected = "", + actual = currentChild().outerHTML + ) } } diff --git a/web/core/src/jsTest/kotlin/elements/ElementsTests.kt b/web/core/src/jsTest/kotlin/elements/ElementsTests.kt index 1b8feab268..19d8b3f6a1 100644 --- a/web/core/src/jsTest/kotlin/elements/ElementsTests.kt +++ b/web/core/src/jsTest/kotlin/elements/ElementsTests.kt @@ -6,9 +6,11 @@ package org.jetbrains.compose.web.core.tests.elements import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import kotlinx.browser.document import org.jetbrains.compose.web.ExperimentalComposeWebApi import org.jetbrains.compose.web.attributes.AttrsBuilder -import org.jetbrains.compose.web.core.tests.runTest +import org.jetbrains.compose.web.testutils.* import org.jetbrains.compose.web.dom.* import org.w3c.dom.HTMLElement import org.w3c.dom.get @@ -131,4 +133,30 @@ class ElementsTests { assertEquals("
CUSTOM
", root.outerHTML) } + + @Test + fun elementBuilderShouldBeCalledOnce() = runTest { + var counter = 0 + var flag = false + + composition { + TagElement({ + counter++ + document.createElement("div") + }, null, + if (flag) { + { Div() { Text("ON") } } + } else null + ) + + } + + assertEquals(1, counter, ) + + flag = true + waitForRecompositionComplete() + + assertEquals(1, counter) + assertEquals("
ON
", nextChild().outerHTML) + } } diff --git a/web/core/src/jsTest/kotlin/elements/EventTests.kt b/web/core/src/jsTest/kotlin/elements/EventTests.kt index eda84323d2..d432b6e8d4 100644 --- a/web/core/src/jsTest/kotlin/elements/EventTests.kt +++ b/web/core/src/jsTest/kotlin/elements/EventTests.kt @@ -13,6 +13,7 @@ import org.w3c.dom.events.MouseEvent import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue +import org.jetbrains.compose.web.testutils.* class EventTests { diff --git a/web/core/src/jsTest/kotlin/elements/InputsGenerateCorrectHtmlTests.kt b/web/core/src/jsTest/kotlin/elements/InputsGenerateCorrectHtmlTests.kt index 9e66107903..f9ab963eda 100644 --- a/web/core/src/jsTest/kotlin/elements/InputsGenerateCorrectHtmlTests.kt +++ b/web/core/src/jsTest/kotlin/elements/InputsGenerateCorrectHtmlTests.kt @@ -4,14 +4,13 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.runtime.getValue import org.jetbrains.compose.web.attributes.* -import org.jetbrains.compose.web.core.tests.asHtmlElement -import org.jetbrains.compose.web.core.tests.runTest import org.jetbrains.compose.web.dom.* import org.jetbrains.compose.web.renderComposable import org.w3c.dom.HTMLInputElement import org.w3c.dom.HTMLTextAreaElement import kotlin.test.Test import kotlin.test.assertEquals +import org.jetbrains.compose.web.testutils.* class InputsGenerateCorrectHtmlTests { diff --git a/web/core/src/jsTest/kotlin/elements/TableTests.kt b/web/core/src/jsTest/kotlin/elements/TableTests.kt index edcc3ad4db..bfeeb9297d 100644 --- a/web/core/src/jsTest/kotlin/elements/TableTests.kt +++ b/web/core/src/jsTest/kotlin/elements/TableTests.kt @@ -19,6 +19,7 @@ import org.jetbrains.compose.web.dom.Tr import org.w3c.dom.HTMLElement import kotlin.test.Test import kotlin.test.assertEquals +import org.jetbrains.compose.web.testutils.* class TableTests { diff --git a/web/gradle.properties b/web/gradle.properties index a73618b9a8..2047134e94 100644 --- a/web/gradle.properties +++ b/web/gradle.properties @@ -1,6 +1,6 @@ # __LATEST_COMPOSE_RELEASE_VERSION__ -COMPOSE_CORE_VERSION=0.0.0-master-build316 -COMPOSE_WEB_VERSION=1.0.0-alpha1 +COMPOSE_CORE_VERSION=1.0.0-alpha4-build321 +COMPOSE_WEB_VERSION=1.0.0-alpha4 compose.web.buildSamples=false compose.web.tests.integration.withFirefox compose.web.tests.skip.benchmarks=false diff --git a/web/internal-web-core-runtime/build.gradle.kts b/web/internal-web-core-runtime/build.gradle.kts new file mode 100644 index 0000000000..442c4e127c --- /dev/null +++ b/web/internal-web-core-runtime/build.gradle.kts @@ -0,0 +1,33 @@ +plugins { + kotlin("multiplatform") + id("org.jetbrains.compose") +} + + +kotlin { + js(IR) { + browser() { + testTask { + testLogging.showStandardStreams = true + useKarma { + useChromeHeadless() + useFirefox() + } + } + } + } + + sourceSets { + val commonMain by getting { + dependencies { + implementation(compose.runtime) + implementation(kotlin("stdlib-common")) + } + } + val jsMain by getting { + dependencies { + implementation(kotlin("stdlib-js")) + } + } + } +} diff --git a/web/internal-web-core-runtime/src/jsMain/kotlin/org/jetbrains/compose/web/internal/runtime/ComposeWebInternalApi.kt b/web/internal-web-core-runtime/src/jsMain/kotlin/org/jetbrains/compose/web/internal/runtime/ComposeWebInternalApi.kt new file mode 100644 index 0000000000..a2c6a4cecc --- /dev/null +++ b/web/internal-web-core-runtime/src/jsMain/kotlin/org/jetbrains/compose/web/internal/runtime/ComposeWebInternalApi.kt @@ -0,0 +1,4 @@ +package org.jetbrains.compose.web.internal.runtime + +@RequiresOptIn("This API is internal and is likely to change in the future.") +annotation class ComposeWebInternalApi diff --git a/web/core/src/jsMain/kotlin/org/jetbrains/compose/web/DomApplier.kt b/web/internal-web-core-runtime/src/jsMain/kotlin/org/jetbrains/compose/web/internal/runtime/DomApplier.kt similarity index 52% rename from web/core/src/jsMain/kotlin/org/jetbrains/compose/web/DomApplier.kt rename to web/internal-web-core-runtime/src/jsMain/kotlin/org/jetbrains/compose/web/internal/runtime/DomApplier.kt index 59ec4db962..bef7baf0cb 100644 --- a/web/core/src/jsMain/kotlin/org/jetbrains/compose/web/DomApplier.kt +++ b/web/internal-web-core-runtime/src/jsMain/kotlin/org/jetbrains/compose/web/internal/runtime/DomApplier.kt @@ -1,14 +1,13 @@ -package org.jetbrains.compose.web +package org.jetbrains.compose.web.internal.runtime import androidx.compose.runtime.AbstractApplier -import org.jetbrains.compose.web.attributes.SyntheticEventListener -import org.jetbrains.compose.web.css.StyleHolder -import org.jetbrains.compose.web.dom.setProperty -import org.jetbrains.compose.web.dom.setVariable import kotlinx.dom.clear import org.w3c.dom.* +import org.w3c.dom.css.CSSStyleDeclaration +import org.w3c.dom.events.EventListener -internal class DomApplier( +@ComposeWebInternalApi +class DomApplier( root: DomNodeWrapper ) : AbstractApplier(root) { @@ -34,28 +33,14 @@ internal class DomApplier( } } -external interface EventListenerOptions { - var once: Boolean - var passive: Boolean - var capture: Boolean -} - -internal open class DomNodeWrapper(open val node: Node) { - private var currentListeners = emptyList>() - - fun updateEventListeners(list: List>) { - val htmlElement = node as? HTMLElement ?: return - - currentListeners.forEach { - htmlElement.removeEventListener(it.event, it) - } - currentListeners = list +@ComposeWebInternalApi +interface NamedEventListener : EventListener { + val name: String +} - currentListeners.forEach { - htmlElement.addEventListener(it.event, it) - } - } +@ComposeWebInternalApi +open class DomNodeWrapper(open val node: Node) { fun insert(index: Int, nodeWrapper: DomNodeWrapper) { val length = node.childNodes.length @@ -88,37 +73,19 @@ internal open class DomNodeWrapper(open val node: Node) { } } +@ComposeWebInternalApi +class DomElementWrapper(override val node: Element): DomNodeWrapper(node) { + private var currentListeners = emptyList() -internal class DomElementWrapper(override val node: HTMLElement): DomNodeWrapper(node) { - private var currentAttrs: Map? = null - - fun updateAttrs(attrs: Map) { - currentAttrs?.forEach { - node.removeAttribute(it.key) - } - - attrs.forEach { - node.setAttribute(it.key, it.value) - } - currentAttrs = attrs - } - - fun updateProperties(list: List Unit, Any>>) { - if (node.className.isNotEmpty()) node.className = "" - - list.forEach { - it.first(node, it.second) + fun updateEventListeners(list: List) { + currentListeners.forEach { + node.removeEventListener(it.name, it) } - } - fun updateStyleDeclarations(style: StyleHolder?) { - node.removeAttribute("style") + currentListeners = list - style?.properties?.forEach { (name, value) -> - setProperty(node.style, name, value) - } - style?.variables?.forEach { (name, value) -> - setVariable(node.style, name, value) + currentListeners.forEach { + node.addEventListener(it.name, it) } } } diff --git a/web/core/src/jsMain/kotlin/org/jetbrains/compose/web/GlobalSnapshotManager.kt b/web/internal-web-core-runtime/src/jsMain/kotlin/org/jetbrains/compose/web/internal/runtime/GlobalSnapshotManager.kt similarity index 92% rename from web/core/src/jsMain/kotlin/org/jetbrains/compose/web/GlobalSnapshotManager.kt rename to web/internal-web-core-runtime/src/jsMain/kotlin/org/jetbrains/compose/web/internal/runtime/GlobalSnapshotManager.kt index 647db56fc5..71183b7576 100644 --- a/web/core/src/jsMain/kotlin/org/jetbrains/compose/web/GlobalSnapshotManager.kt +++ b/web/internal-web-core-runtime/src/jsMain/kotlin/org/jetbrains/compose/web/internal/runtime/GlobalSnapshotManager.kt @@ -1,8 +1,8 @@ -package org.jetbrains.compose.web +package org.jetbrains.compose.web.internal.runtime import androidx.compose.runtime.snapshots.ObserverHandle import androidx.compose.runtime.snapshots.Snapshot -import org.jetbrains.compose.web.GlobalSnapshotManager.ensureStarted +import org.jetbrains.compose.web.internal.runtime.GlobalSnapshotManager.ensureStarted import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch @@ -16,7 +16,8 @@ import kotlinx.coroutines.launch * Composition bootstrapping mechanisms for a particular platform/framework should call * [ensureStarted] during setup to initialize periodic global snapshot notifications. */ -internal object GlobalSnapshotManager { +@ComposeWebInternalApi +object GlobalSnapshotManager { private var started = false private var commitPending = false private var removeWriteObserver: (ObserverHandle)? = null diff --git a/web/core/src/jsMain/kotlin/org/jetbrains/compose/web/JsMicrotasksDispatcher.kt b/web/internal-web-core-runtime/src/jsMain/kotlin/org/jetbrains/compose/web/internal/runtime/JsMicrotasksDispatcher.kt similarity index 68% rename from web/core/src/jsMain/kotlin/org/jetbrains/compose/web/JsMicrotasksDispatcher.kt rename to web/internal-web-core-runtime/src/jsMain/kotlin/org/jetbrains/compose/web/internal/runtime/JsMicrotasksDispatcher.kt index 93b382e3b0..dd64b039a5 100644 --- a/web/core/src/jsMain/kotlin/org/jetbrains/compose/web/JsMicrotasksDispatcher.kt +++ b/web/internal-web-core-runtime/src/jsMain/kotlin/org/jetbrains/compose/web/internal/runtime/JsMicrotasksDispatcher.kt @@ -1,11 +1,12 @@ -package org.jetbrains.compose.web +package org.jetbrains.compose.web.internal.runtime import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Runnable import kotlin.coroutines.CoroutineContext import kotlin.js.Promise -internal class JsMicrotasksDispatcher : CoroutineDispatcher() { +@ComposeWebInternalApi +class JsMicrotasksDispatcher : CoroutineDispatcher() { override fun dispatch(context: CoroutineContext, block: Runnable) { Promise.resolve(Unit).then { block.run() } } diff --git a/web/settings.gradle.kts b/web/settings.gradle.kts index 968b43b02b..5f8c875589 100644 --- a/web/settings.gradle.kts +++ b/web/settings.gradle.kts @@ -30,13 +30,15 @@ fun module(name: String, path: String) { if (!projectDir.exists()) { throw AssertionError("file $projectDir does not exist") } - project(name).projectDir = projectDir + project(name).projectDir = projectDir } module(":web-core", "core") module(":web-widgets", "widgets") module(":compose-compiler-integration", "compose-compiler-integration") +module(":internal-web-core-runtime", "internal-web-core-runtime") +module(":test-utils", "test-utils") if (extra["compose.web.tests.skip.benchmarks"]!!.toString().toBoolean() != true) { module(":web-benchmark-core", "benchmark-core") diff --git a/web/test-utils/build.gradle.kts b/web/test-utils/build.gradle.kts new file mode 100644 index 0000000000..6bf70113a6 --- /dev/null +++ b/web/test-utils/build.gradle.kts @@ -0,0 +1,42 @@ +plugins { + kotlin("multiplatform") + id("org.jetbrains.compose") +} + + +repositories { + mavenCentral() +} + +kotlin { + js(IR) { + browser() { + testTask { + testLogging.showStandardStreams = true + useKarma { + useChromeHeadless() + useFirefox() + } + } + } + } + + sourceSets { + val commonMain by getting { + dependencies { + implementation(kotlin("stdlib-common")) + } + } + val jsMain by getting { + dependencies { + implementation(project(":internal-web-core-runtime")) + implementation(kotlin("stdlib-js")) + } + } + val jsTest by getting { + dependencies { + implementation(kotlin("test-js")) + } + } + } +} diff --git a/web/test-utils/src/jsMain/kotlin/org/jetbrains/compose/web/testutils/ComposeWebExperimentalTestsApi.kt b/web/test-utils/src/jsMain/kotlin/org/jetbrains/compose/web/testutils/ComposeWebExperimentalTestsApi.kt new file mode 100644 index 0000000000..a4221c81c5 --- /dev/null +++ b/web/test-utils/src/jsMain/kotlin/org/jetbrains/compose/web/testutils/ComposeWebExperimentalTestsApi.kt @@ -0,0 +1,4 @@ +package org.jetbrains.compose.web.testutils + +@RequiresOptIn("This API is experimental and is likely to change in the future.") +annotation class ComposeWebExperimentalTestsApi diff --git a/web/core/src/jsTest/kotlin/TestUtils.kt b/web/test-utils/src/jsMain/kotlin/org/jetbrains/compose/web/testutils/TestUtils.kt similarity index 70% rename from web/core/src/jsTest/kotlin/TestUtils.kt rename to web/test-utils/src/jsMain/kotlin/org/jetbrains/compose/web/testutils/TestUtils.kt index 5a0f554b62..0ae538a87e 100644 --- a/web/core/src/jsTest/kotlin/TestUtils.kt +++ b/web/test-utils/src/jsMain/kotlin/org/jetbrains/compose/web/testutils/TestUtils.kt @@ -1,17 +1,14 @@ -package org.jetbrains.compose.web.core.tests +package org.jetbrains.compose.web.testutils -import androidx.compose.runtime.Composable -import androidx.compose.runtime.MonotonicFrameClock -import org.jetbrains.compose.web.renderComposable +import androidx.compose.runtime.* import kotlinx.browser.document import kotlinx.browser.window -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.MainScope +import kotlinx.coroutines.* import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.launch -import kotlinx.coroutines.promise import kotlinx.dom.clear +import org.jetbrains.compose.web.internal.runtime.* import org.w3c.dom.* +import kotlin.coroutines.Continuation import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine import kotlin.time.DurationUnit @@ -23,6 +20,7 @@ import kotlin.time.toDuration * There is no need to create its instances manually. * @see [runTest] */ +@ComposeWebExperimentalTestsApi class TestScope : CoroutineScope by MainScope() { /** @@ -31,7 +29,7 @@ class TestScope : CoroutineScope by MainScope() { */ val root = "div".asHtmlElement() - private val recompositionCompleteEventsChannel = Channel() + private var waitForRecompositionCompleteContinuation: Continuation? = null private val childrenIterator = root.children.asList().listIterator() init { @@ -39,28 +37,58 @@ class TestScope : CoroutineScope by MainScope() { } private fun onRecompositionComplete() { - launch { - recompositionCompleteEventsChannel.send(Unit) - } + waitForRecompositionCompleteContinuation?.resume(Unit) + waitForRecompositionCompleteContinuation = null } /** * Cleans up the [root] content. * Creates a new composition with a given Composable [content]. */ + @ComposeWebExperimentalTestsApi fun composition(content: @Composable () -> Unit) { root.clear() - renderComposable( - root = root, - monotonicFrameClock = TestMonotonicClockImpl( - onRecomposeComplete = this::onRecompositionComplete - ) - ) { + renderTestComposable(root = root) { content() } } + /** + * Use this method to test the composition mounted at [root] + * + * @param root - the [Element] that will be the root of the DOM tree managed by Compose + * @param content - the Composable lambda that defines the composition content + * + * @return the instance of the [Composition] + */ + @OptIn(ComposeWebInternalApi::class) + @ComposeWebExperimentalTestsApi + fun renderTestComposable( + root: TElement, + content: @Composable () -> Unit + ): Composition { + GlobalSnapshotManager.ensureStarted() + + val context = TestMonotonicClockImpl( + onRecomposeComplete = this::onRecompositionComplete + ) + JsMicrotasksDispatcher() + + val recomposer = Recomposer(context) + val composition = ControlledComposition( + applier = DomApplier(DomNodeWrapper(root)), + parent = recomposer + ) + composition.setContent @Composable { + content() + } + + CoroutineScope(context).launch(start = CoroutineStart.UNDISPATCHED) { + recomposer.runRecomposeAndApplyChanges() + } + return composition + } + /** * @return a reference to the next child element of the root. * Subsequent calls will return next child reference every time. @@ -104,7 +132,9 @@ class TestScope : CoroutineScope by MainScope() { * Suspends until recomposition completes. */ suspend fun waitForRecompositionComplete() { - recompositionCompleteEventsChannel.receive() + suspendCoroutine { continuation -> + waitForRecompositionCompleteContinuation = continuation + } } } @@ -140,11 +170,13 @@ class TestScope : CoroutineScope by MainScope() { * } * ``` */ +@ComposeWebExperimentalTestsApi fun runTest(block: suspend TestScope.() -> Unit): dynamic { val scope = TestScope() return scope.promise { block(scope) } } +@ComposeWebExperimentalTestsApi fun String.asHtmlElement() = document.createElement(this) as HTMLElement private object MutationObserverOptions : MutationObserverInit { diff --git a/web/test-utils/src/jsTest/kotlin/TestsForTestUtils.kt b/web/test-utils/src/jsTest/kotlin/TestsForTestUtils.kt new file mode 100644 index 0000000000..a8c1cd50a7 --- /dev/null +++ b/web/test-utils/src/jsTest/kotlin/TestsForTestUtils.kt @@ -0,0 +1,82 @@ +import androidx.compose.runtime.RecomposeScope +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.currentRecomposeScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.jetbrains.compose.web.testutils.ComposeWebExperimentalTestsApi +import org.jetbrains.compose.web.testutils.runTest +import kotlin.test.Test +import kotlin.test.assertEquals + +@OptIn(ComposeWebExperimentalTestsApi::class) +class TestsForTestUtils { + + @Test + fun waitForRecompositionComplete_suspends_and_continues_properly() = runTest { + var recomposeScope: RecomposeScope? = null + + composition { + recomposeScope = currentRecomposeScope + } + delay(100) // to let the initial composition complete + + var waitForRecompositionCompleteContinued = false + + val job = launch { + waitForRecompositionComplete() + waitForRecompositionCompleteContinued = true + } + + delay(100) // to check that `waitForRecompositionComplete` is suspended after delay + assertEquals(false, waitForRecompositionCompleteContinued) + + delay(100) + // we made no changes during 100 ms, so `waitForRecompositionComplete` should remain suspended + assertEquals(false, waitForRecompositionCompleteContinued) + + recomposeScope!!.invalidate() // force recomposition + job.join() + + assertEquals(true, waitForRecompositionCompleteContinued) + } + + @Test + fun waitForChanges_suspends_and_continues_properly() = runTest { + var waitForChangesContinued = false + + var recomposeScope: RecomposeScope? = null + var showText = "" + + composition { + recomposeScope = currentRecomposeScope + + SideEffect { + root.innerText = showText + } + } + + assertEquals("
", root.outerHTML) + + val job = launch { + waitForChanges(root) + waitForChangesContinued = true + } + + delay(100) // to check that `waitForChanges` is suspended after delay + assertEquals(false, waitForChangesContinued) + + // force recomposition and check that `waitForChanges` remains suspended as no changes occurred + recomposeScope!!.invalidate() + waitForRecompositionComplete() + assertEquals(false, waitForChangesContinued) + + // Make changes and check that `waitForChanges` continues + showText = "Hello World!" + recomposeScope!!.invalidate() + waitForRecompositionComplete() + + job.join() + assertEquals(true, waitForChangesContinued) + assertEquals("
Hello World!
", root.outerHTML) + } +}