HTML artifacts: render agent-sent HTML/CSS/JS code blocks in an in-app WebView (Android platform WebView, desktop JCEF/KCEF) via a full-screen preview; artifact card with Preview button + code toggle, shown only once the message stops streaming; webview-multiplatform 1.9.40 + KCEF deps, detection + unit tests
This commit is contained in:
1 parent
8100730cba
commit
a8c4418aff
11 files changed
+479
-3
No files matched your search
@@ -10,6 +10,11 @@ val composeVersion = "1.7.3"
|
||||
val coroutinesVersion = "1.9.0"
|
||||
val serializationVersion = "1.7.3"
|
||||
val okhttpVersion = "4.12.0"
|
||||
// Pinned to 1.9.40: the last release built against Compose 1.7.x (1.7.1);
|
||||
// 2.0.x moves to Compose 1.8+. HTML artifact previews (M9): Android uses the
|
||||
// platform WebView, desktop uses KCEF (JCEF/Chromium, downloaded on first use).
|
||||
val webviewVersion = "1.9.40"
|
||||
val kcefVersion = "2024.04.20.3"
|
||||
// Pinned to 0.33.0: the last release built against Compose 1.7.x / Kotlin 2.1.x
|
||||
// (0.34.0+ moves to Compose 1.8+). Provides GFM tables, bold/italic/underscore,
|
||||
// and the -code module for language-aware syntax highlighting (Highlights).
|
||||
@@ -45,10 +50,19 @@ kotlin {
|
||||
implementation("com.mikepenz:multiplatform-markdown-renderer:$markdownVersion")
|
||||
implementation("com.mikepenz:multiplatform-markdown-renderer-m3:$markdownVersion")
|
||||
implementation("com.mikepenz:multiplatform-markdown-renderer-code:$markdownVersion")
|
||||
// M9: HTML artifact previews — WebView composable (platform WebView
|
||||
// on Android, KCEF/JCEF on desktop).
|
||||
implementation("io.github.kevinnzou:compose-webview-multiplatform:$webviewVersion")
|
||||
}
|
||||
commonTest.dependencies {
|
||||
implementation(kotlin("test"))
|
||||
}
|
||||
// M9: KCEF (JCEF/Chromium) for desktop HTML artifact previews. The
|
||||
// webview library exposes it transitively, but we reference KCEF
|
||||
// directly (init + progress) so declare it explicitly.
|
||||
desktopMain.dependencies {
|
||||
implementation("dev.datlag:kcef:$kcefVersion")
|
||||
}
|
||||
// M4: ExoPlayer (Media3) for inline audio/video playback (Android only).
|
||||
androidMain.dependencies {
|
||||
// M7: EncryptedSharedPreferences for the pairing token
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package iris.platform
|
||||
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.multiplatform.webview.web.WebView
|
||||
import com.multiplatform.webview.web.rememberWebViewStateWithHTMLData
|
||||
|
||||
/**
|
||||
* M9: HTML artifact preview on Android — the platform WebView (JavaScript
|
||||
* enabled by default in the library's WebSettings).
|
||||
*/
|
||||
@Composable
|
||||
actual fun PlatformWebView(html: String, modifier: Modifier) {
|
||||
val state = rememberWebViewStateWithHTMLData(data = html)
|
||||
WebView(state, modifier = modifier)
|
||||
}
|
||||
|
||||
@Composable
|
||||
actual fun Modifier.handleSystemBack(onBack: () -> Unit): Modifier {
|
||||
val dispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher
|
||||
val currentOnBack = rememberUpdatedState(onBack)
|
||||
DisposableEffect(dispatcher) {
|
||||
if (dispatcher != null) {
|
||||
val callback = object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
currentOnBack.value()
|
||||
}
|
||||
}
|
||||
dispatcher.addCallback(callback)
|
||||
onDispose { callback.remove() }
|
||||
}
|
||||
onDispose { }
|
||||
}
|
||||
return this
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import iris.data.SecureStore
|
||||
import iris.net.GatewayClient
|
||||
import iris.platform.setActiveController
|
||||
import iris.state.IrisController
|
||||
import iris.ui.ArtifactPreviewOverlay
|
||||
import iris.ui.screens.ChatScreen
|
||||
import iris.ui.screens.ConnectScreen
|
||||
import iris.ui.theme.IrisColors
|
||||
@@ -96,6 +97,9 @@ fun IrisApp(
|
||||
// without blocking the view (M7's full-screen spinner is gone).
|
||||
else -> ChatScreen(controller)
|
||||
}
|
||||
// M9: full-screen HTML artifact preview (opened from an
|
||||
// artifact card in the chat); covers everything while open.
|
||||
ArtifactPreviewOverlay()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package iris.platform
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
|
||||
/**
|
||||
* M9: renders [html] in an in-app WebView with JavaScript enabled.
|
||||
*
|
||||
* Platform actuals: Android → the platform WebView; desktop → JCEF (KCEF),
|
||||
* whose Chromium binary is downloaded on first use (the caller shows a
|
||||
* progress indicator until it is ready).
|
||||
*/
|
||||
@Composable
|
||||
expect fun PlatformWebView(html: String, modifier: Modifier)
|
||||
|
||||
/** Consumes the system back button (Android) while applied; no-op elsewhere. */
|
||||
@Composable
|
||||
expect fun Modifier.handleSystemBack(onBack: () -> Unit): Modifier
|
||||
@@ -0,0 +1,212 @@
|
||||
package iris.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Code
|
||||
import androidx.compose.material.icons.filled.CodeOff
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.snipme.highlights.Highlights
|
||||
import iris.platform.PlatformWebView
|
||||
import iris.platform.handleSystemBack
|
||||
import iris.ui.theme.IrisColors
|
||||
|
||||
/**
|
||||
* M9: HTML artifact support. When the agent emits a complete HTML document
|
||||
* (HTML + CSS + JS) in a fenced code block, the bubble renders an artifact
|
||||
* card instead of plain highlighted code. "Preview" opens the document in a
|
||||
* full-screen in-app WebView with JavaScript enabled, so interactive scripts
|
||||
* run inside the app.
|
||||
*/
|
||||
|
||||
/**
|
||||
* True when a fenced code block should be rendered as a runnable HTML
|
||||
* artifact: an explicit ```html / ```htm language tag, or an untagged block
|
||||
* that starts with a complete HTML document.
|
||||
*/
|
||||
fun isHtmlArtifact(language: String?, code: String): Boolean {
|
||||
val lang = language?.trim()?.lowercase()
|
||||
if (lang == "html" || lang == "htm") return true
|
||||
if (lang != null) return false
|
||||
val head = code.trimStart().take(64).lowercase()
|
||||
return head.startsWith("<!doctype html") || head.startsWith("<html")
|
||||
}
|
||||
|
||||
/** The document's <title> (card / overlay header), or null when absent. */
|
||||
fun htmlTitle(code: String): String? =
|
||||
TITLE_REGEX.find(code)?.groupValues?.get(1)?.trim()?.takeIf { it.isNotEmpty() }
|
||||
|
||||
private val TITLE_REGEX =
|
||||
Regex("<title[^>]*>(.*?)</title>", setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL))
|
||||
|
||||
/** The artifact currently open in the full-screen preview (null = closed). */
|
||||
data class ArtifactPreviewData(val html: String, val title: String?)
|
||||
|
||||
/**
|
||||
* Process-wide preview state: the artifact card (deep in the message list)
|
||||
* writes here, the overlay at the app root reads it.
|
||||
*/
|
||||
object ArtifactPreview {
|
||||
private val _current = mutableStateOf<ArtifactPreviewData?>(null)
|
||||
val current: State<ArtifactPreviewData?> get() = _current
|
||||
|
||||
fun show(html: String, title: String?) {
|
||||
_current.value = ArtifactPreviewData(html, title)
|
||||
}
|
||||
|
||||
fun hide() {
|
||||
_current.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Artifact card shown in place of a highlighted HTML code block: title row
|
||||
* with a Preview button (opens the full-screen overlay) and a code toggle
|
||||
* (reveals the highlighted source with its copy button).
|
||||
*/
|
||||
@Composable
|
||||
fun HtmlArtifactCard(
|
||||
code: String,
|
||||
style: TextStyle,
|
||||
highlights: Highlights.Builder,
|
||||
) {
|
||||
val title = remember(code) { htmlTitle(code) }
|
||||
var showCode by remember { mutableStateOf(false) }
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = IrisColors.panel,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(start = 12.dp, top = 6.dp, bottom = 6.dp, end = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Code,
|
||||
contentDescription = null,
|
||||
tint = IrisColors.statusGreen,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = title ?: "HTML artifact",
|
||||
color = IrisColors.textBright,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = "Interactive HTML preview",
|
||||
color = IrisColors.textMuted,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Button(
|
||||
onClick = { ArtifactPreview.show(code, title) },
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(
|
||||
horizontal = 12.dp,
|
||||
vertical = 4.dp,
|
||||
),
|
||||
) {
|
||||
Text("Preview", style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
IconButton(onClick = { showCode = !showCode }) {
|
||||
Icon(
|
||||
imageVector = if (showCode) Icons.Filled.CodeOff else Icons.Filled.Code,
|
||||
contentDescription = if (showCode) "Hide code" else "Show code",
|
||||
tint = IrisColors.textSecondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (showCode) {
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
CodeBlockWithCopy(code = code, language = "html", style = style, highlights = highlights)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-screen artifact preview overlay (rendered at the app root). Covers the
|
||||
* whole window; the header close button and the system back button (Android)
|
||||
* dismiss it.
|
||||
*/
|
||||
@Composable
|
||||
fun ArtifactPreviewOverlay() {
|
||||
val preview by ArtifactPreview.current
|
||||
if (preview == null) return
|
||||
val data = preview!!
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(IrisColors.background)
|
||||
.handleSystemBack { ArtifactPreview.hide() },
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 8.dp, end = 2.dp, top = 4.dp, bottom = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Code,
|
||||
contentDescription = null,
|
||||
tint = IrisColors.statusGreen,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
Text(
|
||||
text = data.title ?: "HTML preview",
|
||||
modifier = Modifier.weight(1f),
|
||||
color = IrisColors.textBright,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
IconButton(onClick = { ArtifactPreview.hide() }) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Close,
|
||||
contentDescription = "Close preview",
|
||||
tint = IrisColors.textSecondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
HorizontalDivider(color = IrisColors.divider)
|
||||
PlatformWebView(html = data.html, modifier = Modifier.fillMaxSize().weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,10 @@ fun MarkdownText(
|
||||
color: Color,
|
||||
fontSize: TextUnit,
|
||||
modifier: Modifier = Modifier,
|
||||
// M9: while the message is still streaming, HTML code blocks render as
|
||||
// plain highlighted code — the artifact card (and its runnable preview)
|
||||
// only appears once the message is complete.
|
||||
isStreaming: Boolean = false,
|
||||
) {
|
||||
val state = rememberMarkdownState(text)
|
||||
// The app is always dark-themed, so force the dark highlight palette
|
||||
@@ -159,12 +163,20 @@ fun MarkdownText(
|
||||
components = markdownComponents(
|
||||
codeFence = { model ->
|
||||
MarkdownCodeFence(model.content, model.node, model.typography.code) { code, language, style ->
|
||||
CodeBlockWithCopy(code = code, language = language, style = style, highlights = highlights)
|
||||
if (!isStreaming && isHtmlArtifact(language, code)) {
|
||||
HtmlArtifactCard(code = code, style = style, highlights = highlights)
|
||||
} else {
|
||||
CodeBlockWithCopy(code = code, language = language, style = style, highlights = highlights)
|
||||
}
|
||||
}
|
||||
},
|
||||
codeBlock = { model ->
|
||||
MarkdownCodeBlock(model.content, model.node, model.typography.code) { code, language, style ->
|
||||
CodeBlockWithCopy(code = code, language = language, style = style, highlights = highlights)
|
||||
if (!isStreaming && isHtmlArtifact(language, code)) {
|
||||
HtmlArtifactCard(code = code, style = style, highlights = highlights)
|
||||
} else {
|
||||
CodeBlockWithCopy(code = code, language = language, style = style, highlights = highlights)
|
||||
}
|
||||
}
|
||||
},
|
||||
// The core default checkbox renders literal "[x]"/"[ ]" text; use the
|
||||
@@ -184,9 +196,10 @@ fun MarkdownText(
|
||||
/**
|
||||
* Renders a highlighted code block with a copy button in the top-right corner
|
||||
* (Telegram-style). Tapping the button copies the whole block to the clipboard.
|
||||
* Also used by [HtmlArtifactCard] to reveal the source of an HTML artifact.
|
||||
*/
|
||||
@Composable
|
||||
private fun CodeBlockWithCopy(
|
||||
internal fun CodeBlockWithCopy(
|
||||
code: String,
|
||||
language: String?,
|
||||
style: TextStyle,
|
||||
|
||||
@@ -1921,6 +1921,7 @@ private fun MessageBubble(
|
||||
if (msg.streaming) " ▉" else "",
|
||||
color = textColor,
|
||||
fontSize = 15.sp,
|
||||
isStreaming = msg.streaming,
|
||||
)
|
||||
}
|
||||
// M7: timestamp + delivery status on their own line at the
|
||||
@@ -1963,6 +1964,7 @@ private fun MessageBubble(
|
||||
color = textColor,
|
||||
fontSize = if (isCommentary) 13.sp else 15.sp,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
isStreaming = msg.streaming,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package iris.ui
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class HtmlArtifactTest {
|
||||
|
||||
@Test
|
||||
fun htmlLanguageTagIsArtifact() {
|
||||
assertTrue(isHtmlArtifact("html", "<p>hi</p>"))
|
||||
assertTrue(isHtmlArtifact("htm", "<p>hi</p>"))
|
||||
assertTrue(isHtmlArtifact("HTML", "<p>hi</p>"))
|
||||
assertTrue(isHtmlArtifact(" html ", "<p>hi</p>"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun otherLanguageTagsAreNotArtifacts() {
|
||||
assertFalse(isHtmlArtifact("css", "<style>x</style>"))
|
||||
assertFalse(isHtmlArtifact("js", "console.log(1)"))
|
||||
assertFalse(isHtmlArtifact("kotlin", "fun main() {}"))
|
||||
// Even a full document inside a non-html fence is not an artifact.
|
||||
assertFalse(isHtmlArtifact("text", "<!DOCTYPE html><html></html>"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun untaggedCompleteDocumentIsArtifact() {
|
||||
assertTrue(isHtmlArtifact(null, "<!DOCTYPE html>\n<html><body>hi</body></html>"))
|
||||
assertTrue(isHtmlArtifact(null, " <html><body>hi</body></html>"))
|
||||
assertTrue(isHtmlArtifact(null, "<HTML><BODY>hi</BODY></HTML>"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun untaggedFragmentIsNotArtifact() {
|
||||
assertFalse(isHtmlArtifact(null, "<div>just a fragment</div>"))
|
||||
assertFalse(isHtmlArtifact(null, "Some prose with <b>bold</b> inside."))
|
||||
assertFalse(isHtmlArtifact(null, ""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun extractsTitle() {
|
||||
assertEquals(
|
||||
"My Page",
|
||||
htmlTitle("<html><head><title>My Page</title></head><body></body></html>"),
|
||||
)
|
||||
assertEquals(
|
||||
"Spaced",
|
||||
htmlTitle("<title> Spaced </title>"),
|
||||
)
|
||||
// Case-insensitive, attributes allowed.
|
||||
assertEquals(
|
||||
"X",
|
||||
htmlTitle("<TITLE lang=\"en\">X</TITLE>"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun missingOrEmptyTitleIsNull() {
|
||||
assertNull(htmlTitle("<html><body>no title</body></html>"))
|
||||
assertNull(htmlTitle("<title></title>"))
|
||||
assertNull(htmlTitle("<title> </title>"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package iris.platform
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.multiplatform.webview.web.WebView
|
||||
import com.multiplatform.webview.web.rememberWebViewStateWithHTMLData
|
||||
import dev.datlag.kcef.KCEF
|
||||
import iris.ui.theme.IrisColors
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* M9: HTML artifact preview on desktop — JCEF (Chromium) via KCEF.
|
||||
*
|
||||
* KCEF is initialized once per process, lazily on the first artifact
|
||||
* preview. The JCEF/Chromium binary is downloaded from the pinned
|
||||
* JetBrainsRuntime release on first use and cached in ~/.iris/kcef-bundle,
|
||||
* so later starts (and the packaged app) reuse it.
|
||||
*/
|
||||
private val kcefReady = MutableStateFlow(false)
|
||||
private val kcefProgress = MutableStateFlow(0f)
|
||||
private val kcefError = MutableStateFlow<String?>(null)
|
||||
|
||||
private suspend fun ensureKcefInitialized() {
|
||||
if (kcefReady.value) return
|
||||
withContext(Dispatchers.IO) {
|
||||
KCEF.init(
|
||||
builder = {
|
||||
installDir(File(System.getProperty("user.home"), ".iris/kcef-bundle"))
|
||||
// Pinned to the JBR 17 release the KCEF 2024.04.20.x line was
|
||||
// built against; the default "latest" JBR may be JDK 21-based
|
||||
// and is not guaranteed compatible.
|
||||
download {
|
||||
github { release("jbr-release-17.0.12b1207.37") }
|
||||
}
|
||||
settings {
|
||||
cachePath = File(System.getProperty("user.home"), ".iris/kcef-cache").absolutePath
|
||||
}
|
||||
progress {
|
||||
onDownloading { kcefProgress.value = it }
|
||||
onInitialized { kcefReady.value = true }
|
||||
}
|
||||
},
|
||||
onError = { kcefError.value = it?.message ?: "Preview engine failed to start" },
|
||||
onRestartRequired = { kcefError.value = "Restart the app to finish setting up previews" },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
actual fun PlatformWebView(html: String, modifier: Modifier) {
|
||||
val ready by kcefReady.collectAsState()
|
||||
val progress by kcefProgress.collectAsState()
|
||||
val error by kcefError.collectAsState()
|
||||
LaunchedEffect(Unit) { ensureKcefInitialized() }
|
||||
Box(modifier = modifier.fillMaxSize()) {
|
||||
when {
|
||||
error != null -> {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(error!!, color = IrisColors.errorText)
|
||||
}
|
||||
}
|
||||
!ready -> {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
CircularProgressIndicator(progress = { progress })
|
||||
Text(
|
||||
text = "Preparing preview… ${progress.toInt()}%",
|
||||
color = IrisColors.textSecondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
val state = rememberWebViewStateWithHTMLData(data = html)
|
||||
WebView(state, modifier = Modifier.fillMaxSize())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
actual fun Modifier.handleSystemBack(onBack: () -> Unit): Modifier = this
|
||||
Reference in new issue
Block a user