diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedWebAppController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedWebAppController.kt index 305ba16e0d..8de6744146 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedWebAppController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedWebAppController.kt @@ -43,6 +43,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedImeBridge import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedLoadStatus import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedSurfaceController import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.ImeEvent +import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.parseSelectionGeometry import org.json.JSONObject import java.util.concurrent.atomic.AtomicLong @@ -256,6 +257,7 @@ class EmbeddedWebAppController( text = o.optString("text", ""), selStart = o.optInt("selStart", 0), selEnd = o.optInt("selEnd", 0), + geometry = parseSelectionGeometry(o.optJSONObject("geom")), ) "ime.blur" -> ImeEvent.Blur "ime.state" -> @@ -263,6 +265,13 @@ class EmbeddedWebAppController( text = o.optString("text", ""), selStart = o.optInt("selStart", 0), selEnd = o.optInt("selEnd", 0), + geometry = parseSelectionGeometry(o.optJSONObject("geom")), + ) + "ime.pagesel" -> + ImeEvent.PageSelection( + active = o.optBoolean("active", false), + text = o.optString("text", ""), + geometry = parseSelectionGeometry(o.optJSONObject("geom")), ) else -> null } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedImeBridge.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedImeBridge.kt index 35c0375c0f..08526f460b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedImeBridge.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedImeBridge.kt @@ -20,6 +20,27 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.embed +import org.json.JSONObject + +/** Parses the `geom` object of an `ime.pagesel` payload into a [SelectionGeometry], or null if absent. */ +fun parseSelectionGeometry(o: JSONObject?): SelectionGeometry? { + if (o == null) return null + return SelectionGeometry( + left = o.optDouble("l", 0.0).toFloat(), + top = o.optDouble("t", 0.0).toFloat(), + right = o.optDouble("r", 0.0).toFloat(), + bottom = o.optDouble("b", 0.0).toFloat(), + startX = o.optDouble("sx", 0.0).toFloat(), + startBottom = o.optDouble("sb", 0.0).toFloat(), + endX = o.optDouble("ex", 0.0).toFloat(), + endBottom = o.optDouble("eb", 0.0).toFloat(), + viewportWidth = o.optDouble("vw", 0.0).toFloat(), + caretX = if (o.has("cx")) o.optDouble("cx").toFloat() else null, + caretTop = if (o.has("ct")) o.optDouble("ct").toFloat() else null, + caretBottom = if (o.has("cb")) o.optDouble("cb").toFloat() else null, + ) +} + /** * A cross-process editable: the focused field lives in the embedded WebView (a different process/window * that can't host the soft keyboard), so the main app keeps the keyboard and relays editing across this @@ -44,6 +65,7 @@ sealed interface ImeEvent { val text: String, val selStart: Int, val selEnd: Int, + val geometry: SelectionGeometry? = null, ) : ImeEvent /** The field lost focus — dismiss the keyboard. */ @@ -54,5 +76,39 @@ sealed interface ImeEvent { val text: String, val selStart: Int, val selEnd: Int, + val geometry: SelectionGeometry? = null, + ) : ImeEvent + + /** + * Ordinary page text (not an input) gained or lost a selection. The embedded WebView can't present + * Chrome's copy toolbar/handles in its cross-process surface, so the host draws its own over the page; + * [text] is the selected text to put on the clipboard, [geometry] places the toolbar + drag handles. + */ + data class PageSelection( + val active: Boolean, + val text: String, + val geometry: SelectionGeometry?, ) : ImeEvent } + +/** + * Selection geometry in the page's CSS px (viewport coords). The host scales by surface-width / [viewportWidth] + * to screen px. [left]/[top]/[right]/[bottom] is the bounding box (toolbar anchors above it); ([startX], + * [startBottom]) and ([endX], [endBottom]) are the caret feet where the two drag handles sit. + */ +data class SelectionGeometry( + val left: Float, + val top: Float, + val right: Float, + val bottom: Float, + val startX: Float, + val startBottom: Float, + val endX: Float, + val endBottom: Float, + val viewportWidth: Float, + // Present only when the field's selection is a bare caret (no range): the caret rect, so the host can + // show a draggable insertion handle below it. Null for a range or page-text selection. + val caretX: Float? = null, + val caretTop: Float? = null, + val caretBottom: Float? = null, +) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabLayer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabLayer.kt index cf79f727da..b0df00d15f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabLayer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabLayer.kt @@ -20,19 +20,34 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.embed +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context import android.os.Build import android.view.ViewGroup import androidx.annotation.RequiresApi +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.absoluteOffset import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imeAnimationTarget +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.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -40,18 +55,26 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInWindow import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.privacysandbox.ui.client.view.SandboxedSdkView import kotlinx.coroutines.delay +import org.json.JSONObject +import kotlin.math.roundToInt // How far off-screen a parked (inactive) warm tab is shifted — well past any real screen width. private val OFFSCREEN_SHIFT = 10_000.dp @@ -121,10 +144,15 @@ fun EmbeddedTabLayer(barFavoriteIds: List) { // surface re-render, no black flash between tabs. (Parking at 1dp forced a // resize + re-render on every switch, which flashed black for ~1s.) val left = (bounds.left - layerOrigin.x).toDp() + (if (active) 0.dp else OFFSCREEN_SHIFT) - val imeOverlap = if (active) (imeBottomPx - (layerOrigin.y + layerSize.height - bounds.bottom)).coerceAtLeast(0f) else 0f + // Do NOT shrink the surface for the keyboard: resizing reconfigures the cross-process + // SurfaceControlViewHost surface, and the first frame presented after that reconfigure + // stalls ~1s (the per-focus "freeze"). Keep the surface full-size and let the page bring + // the focused field above the keyboard via the shim's scrollIntoView on focus. + @Suppress("UNUSED_EXPRESSION") + imeBottomPx Modifier .absoluteOffset(left, (bounds.top - layerOrigin.y).toDp()) - .size(bounds.width.toDp(), (bounds.height - imeOverlap).coerceAtLeast(1f).toDp()) + .size(bounds.width.toDp(), bounds.height.toDp()) } } else { // No content bounds reported yet: park tiny off-screen until a tab is shown. @@ -267,17 +295,47 @@ fun EmbeddedTabLayer(barFavoriteIds: List) { val context = LocalContext.current val imeBridge = EmbeddedTabHost.sessions.firstOrNull { it.id == activeId }?.controller as? EmbeddedImeBridge val imeView = remember { RemoteImeView(context) } + // The embedded WebView can't show Chrome's copy/paste toolbar in its cross-process surface, so when + // the page has a non-empty selection we show our own here, over the page, and route its actions to + // the hidden EditText (which mirrors the selection) — see [RemoteImeView.onRangeSelectionChanged]. + var showSelectionToolbar by remember { mutableStateOf(false) } + var fieldGeometry by remember { mutableStateOf(null) } + // Insertion (cursor) handle visibility: shown when a tap places/moves a bare caret, hidden while + // typing — like Android. A caret-bearing geometry update means a tap; an edit means typing. + var showInsertionHandle by remember { mutableStateOf(false) } + var pageSelection by remember { mutableStateOf(null) } DisposableEffect(imeBridge) { imeView.bind(imeBridge) + imeView.onRangeSelectionChanged = { showSelectionToolbar = it } + imeView.onEdited = { showInsertionHandle = false } imeBridge?.onImeEvent = { event -> when (event) { - is ImeEvent.Focus -> imeView.onPageFocus(event) - ImeEvent.Blur -> imeView.onPageBlur() - is ImeEvent.State -> imeView.onPageState(event) + is ImeEvent.Focus -> { + imeView.onPageFocus(event) + event.geometry?.let { fieldGeometry = it } + if (event.geometry?.caretX != null) showInsertionHandle = true + } + ImeEvent.Blur -> { + imeView.onPageBlur() + fieldGeometry = null + showInsertionHandle = false + } + is ImeEvent.State -> { + imeView.onPageState(event) + event.geometry?.let { fieldGeometry = it } + if (event.geometry?.caretX != null) showInsertionHandle = true + } + is ImeEvent.PageSelection -> pageSelection = event.takeIf { it.active } } } onDispose { imeBridge?.onImeEvent = null + imeView.onRangeSelectionChanged = null + imeView.onEdited = null + showSelectionToolbar = false + fieldGeometry = null + showInsertionHandle = false + pageSelection = null imeView.onPageBlur() imeView.bind(null) } @@ -293,5 +351,315 @@ fun EmbeddedTabLayer(barFavoriteIds: List) { ).size(1.dp), ) } + + // Input-field selection: cut/copy/paste/select-all routed to the hidden EditText. The DOM exposes no + // rect for a selection *inside* an input, so we anchor the bar above the field box (reported by the + // shim), falling back to the top of the tab if the field geometry is missing. + if (showSelectionToolbar && bounds.width > 0f && bounds.height > 0f) { + val fg = fieldGeometry + val items = + listOf( + "Cut" to { + imeView.cutSelection() + Unit + }, + "Copy" to { + imeView.copySelection() + Unit + }, + "Paste" to { + imeView.pasteClipboard() + Unit + }, + "Select all" to { + imeView.selectAllText() + Unit + }, + ) + val originX = bounds.left - layerOrigin.x + val originY = bounds.top - layerOrigin.y + val toolbarH = with(density) { 44.dp.toPx() } + val gap = with(density) { 8.dp.toPx() } + if (fg != null && fg.viewportWidth > 0f) { + val scale = bounds.width / fg.viewportWidth + val topPx = originY + fg.top * scale + val toolbarY = if (topPx - toolbarH - gap > originY) topPx - toolbarH - gap else originY + fg.bottom * scale + gap + EmbeddedSelectionToolbar( + items = items, + centerXpx = originX + ((fg.left + fg.right) / 2f) * scale, + topYpx = toolbarY, + ) + } else { + EmbeddedSelectionToolbar( + items = items, + centerXpx = originX + bounds.width / 2f, + topYpx = originY + gap, + ) + } + } + + // Bare caret in a field (no range): a draggable insertion handle under the cursor, like Android's. + val fgCaret = fieldGeometry + if (showInsertionHandle && !showSelectionToolbar && fgCaret?.caretX != null && fgCaret.caretBottom != null && + fgCaret.viewportWidth > 0f && bounds.width > 0f && bounds.height > 0f + ) { + val scale = bounds.width / fgCaret.viewportWidth + val originX = bounds.left - layerOrigin.x + val originY = bounds.top - layerOrigin.y + InsertionHandle( + tipPx = Offset(originX + fgCaret.caretX * scale, originY + fgCaret.caretBottom * scale), + surfaceOriginX = originX, + surfaceOriginY = originY, + scale = scale, + onDragTo = { cssX, cssY -> + imeBridge?.sendImeOp( + JSONObject() + .put("type", "ime.caretmove") + .put("x", cssX.toDouble()) + .put("y", cssY.toDouble()) + .toString(), + ) + }, + ) + } + + // Plain page-text selection: a Copy bar positioned over the selection + a drag handle at each end. + val pageSel = pageSelection + val geom = pageSel?.geometry + if (geom != null && geom.viewportWidth > 0f && bounds.width > 0f && bounds.height > 0f) { + PageSelectionOverlay( + geometry = geom, + surfaceOriginX = bounds.left - layerOrigin.x, + surfaceOriginY = bounds.top - layerOrigin.y, + scale = bounds.width / geom.viewportWidth, + onCopy = { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText("selection", pageSel.text)) + }, + onExtend = { edge, cssX, cssY -> + imeBridge?.sendImeOp( + JSONObject() + .put("type", "ime.pageextend") + .put("edge", edge) + .put("x", cssX.toDouble()) + .put("y", cssY.toDouble()) + .toString(), + ) + }, + ) + } } } + +/** + * Host-drawn selection overlay for a plain page-text selection: a Copy bar above the selection plus a + * draggable handle at each end. [geometry] is in the page's CSS px; [scale] and the surface origin map those + * to this layer's px. Dragging a handle reports the new CSS-px target via [onExtend], which the shim turns + * into a selection extension; the resulting geometry update repositions everything. + */ +@Composable +private fun PageSelectionOverlay( + geometry: SelectionGeometry, + surfaceOriginX: Float, + surfaceOriginY: Float, + scale: Float, + onCopy: () -> Unit, + onExtend: (edge: String, cssX: Float, cssY: Float) -> Unit, +) { + val density = LocalDensity.current + + fun mapX(css: Float) = surfaceOriginX + css * scale + + fun mapY(css: Float) = surfaceOriginY + css * scale + + val toolbarH = with(density) { 44.dp.toPx() } + val gap = with(density) { 8.dp.toPx() } + val topPx = mapY(geometry.top) + val toolbarY = if (topPx - toolbarH - gap > surfaceOriginY) topPx - toolbarH - gap else mapY(geometry.bottom) + gap + + EmbeddedSelectionToolbar( + items = listOf("Copy" to onCopy), + centerXpx = mapX((geometry.left + geometry.right) / 2f), + topYpx = toolbarY, + ) + + SelectionHandle(Offset(mapX(geometry.startX), mapY(geometry.startBottom)), isStart = true, surfaceOriginX, surfaceOriginY, scale) { x, y -> onExtend("start", x, y) } + SelectionHandle(Offset(mapX(geometry.endX), mapY(geometry.endBottom)), isStart = false, surfaceOriginX, surfaceOriginY, scale) { x, y -> onExtend("end", x, y) } +} + +/** + * A draggable selection-endpoint handle drawn as Android's teardrop: a disc with one squared corner that + * points to the caret. The start handle points up-right (hangs to the left of the selection start); the end + * handle points up-left. [tipPx] is the caret foot (this layer's px) where the point sits; a drag reports + * the new tip position in the page's CSS px. + */ +@Composable +private fun SelectionHandle( + tipPx: Offset, + isStart: Boolean, + surfaceOriginX: Float, + surfaceOriginY: Float, + scale: Float, + onDragTo: (cssX: Float, cssY: Float) -> Unit, +) { + val sizeDp = 22.dp + val sizePx = with(LocalDensity.current) { sizeDp.toPx() } + val color = MaterialTheme.colorScheme.primary + val currentTip by rememberUpdatedState(tipPx) + var dragTip by remember { mutableStateOf(null) } + val tip = dragTip ?: tipPx + // Place the box so its pointed corner lands on the tip: start = top-right corner, end = top-left corner. + val boxLeft = if (isStart) tip.x - sizePx else tip.x + Box( + Modifier + .absoluteOffset { IntOffset(boxLeft.roundToInt(), tip.y.roundToInt()) } + .size(sizeDp) + .pointerInput(Unit) { + detectDragGestures( + onDragStart = { dragTip = currentTip }, + onDrag = { change, delta -> + change.consume() + val np = (dragTip ?: currentTip) + delta + dragTip = np + onDragTo((np.x - surfaceOriginX) / scale, (np.y - surfaceOriginY) / scale) + }, + onDragEnd = { dragTip = null }, + onDragCancel = { dragTip = null }, + ) + }, + ) { + Canvas(Modifier.fillMaxSize()) { + val d = size.minDimension + val path = + Path().apply { + if (isStart) { + moveTo(d, 0f) + lineTo(d, d / 2f) + arcTo(Rect(0f, 0f, d, d), 0f, 270f, false) + close() + } else { + moveTo(0f, 0f) + lineTo(0f, d / 2f) + arcTo(Rect(0f, 0f, d, d), 180f, -270f, false) + close() + } + } + drawPath(path, color) + } + } +} + +/** + * A draggable insertion (cursor) handle drawn as Android's upward teardrop, hanging below a bare caret with + * its tip on the caret. [tipPx] is the caret foot (this layer's px); a drag reports the new caret position in + * the page's CSS px, which the shim maps back to a character offset. + */ +@Composable +private fun InsertionHandle( + tipPx: Offset, + surfaceOriginX: Float, + surfaceOriginY: Float, + scale: Float, + onDragTo: (cssX: Float, cssY: Float) -> Unit, +) { + val density = LocalDensity.current + val wPx = with(density) { 20.dp.toPx() } + val hPx = wPx * 1.4f + val color = MaterialTheme.colorScheme.primary + val currentTip by rememberUpdatedState(tipPx) + // Track the finger separately from what we draw: the handle renders at the AUTHORITATIVE caret (tipPx, + // which snaps to a character position as the move round-trips through the shim), while the finger drives + // the move. So the handle stays glued to the line/text and clamps to the field instead of trailing off. + var fingerPx by remember { mutableStateOf(null) } + Box( + Modifier + .absoluteOffset { IntOffset((tipPx.x - wPx / 2f).roundToInt(), tipPx.y.roundToInt()) } + .size(with(density) { wPx.toDp() }, with(density) { hPx.toDp() }) + .pointerInput(Unit) { + detectDragGestures( + onDragStart = { fingerPx = currentTip }, + onDrag = { change, delta -> + change.consume() + val np = (fingerPx ?: currentTip) + delta + fingerPx = np + onDragTo((np.x - surfaceOriginX) / scale, (np.y - surfaceOriginY) / scale) + }, + onDragEnd = { fingerPx = null }, + onDragCancel = { fingerPx = null }, + ) + }, + ) { + Canvas(Modifier.fillMaxSize()) { + val r = size.width / 2f + val cx = size.width / 2f + val cy = size.height - r + val path = + Path().apply { + moveTo(cx, 0f) + lineTo(cx + r, cy) + arcTo(Rect(cx - r, cy - r, cx + r, cy + r), 0f, 180f, false) + lineTo(cx, 0f) + close() + } + drawPath(path, color) + } + } +} + +/** + * Host-drawn copy/paste bar for an embedded page selection. Chrome can't present its own action-mode in the + * cross-process surface, so this floats over the active tab; each action runs on the hidden [RemoteImeView] + * (which mirrors the page's text + selection), so cut/paste relay back to the page through the normal path. + * Uses pointer-input rather than `clickable` so tapping it doesn't pull focus off the EditText (which would + * drop the keyboard and the selection). + */ +@Composable +private fun EmbeddedSelectionToolbar( + items: List Unit>>, + centerXpx: Float, + topYpx: Float, +) { + // Self-center: the toolbar's width depends on its items, so measure it and offset by half-width around + // the requested centre-x (a one-frame left-aligned flash before the first measurement is acceptable). + var widthPx by remember { mutableStateOf(0) } + Surface( + modifier = + Modifier + .absoluteOffset { IntOffset((centerXpx - widthPx / 2f).roundToInt(), topYpx.roundToInt()) } + .onGloballyPositioned { widthPx = it.size.width }, + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surface, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + tonalElevation = 3.dp, + shadowElevation = 6.dp, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + items.forEachIndexed { i, (label, action) -> + if (i > 0) { + Box( + Modifier + .width(1.dp) + .height(20.dp) + .background(MaterialTheme.colorScheme.outlineVariant), + ) + } + SelectionToolbarItem(label, action) + } + } + } +} + +@Composable +private fun SelectionToolbarItem( + label: String, + onClick: () -> Unit, +) { + Text( + text = label, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = + Modifier + .pointerInput(label) { detectTapGestures(onTap = { onClick() }) } + .padding(horizontal = 12.dp, vertical = 10.dp), + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/RemoteImeView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/RemoteImeView.kt index 840a5e0ce7..6c2b8d0379 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/RemoteImeView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/RemoteImeView.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.embed import android.annotation.SuppressLint import android.content.Context +import android.os.SystemClock import android.text.Editable import android.text.InputType import android.text.TextWatcher @@ -93,7 +94,10 @@ class RemoteImeView( count: Int, ) {} - override fun afterTextChanged(s: Editable?) = schedule() + override fun afterTextChanged(s: Editable?) { + if (!applyingRemote) onEdited?.invoke() + schedule() + } }, ) // The IME's "Go/Search/Send/Done" — the page submits/handles it (single-line has no newline). @@ -108,26 +112,85 @@ class RemoteImeView( this.bridge = bridge } + /** + * Notified when the mirrored selection becomes (true) or stops being (false) a non-empty range. The + * embedded WebView can't present Chrome's copy/paste toolbar in its cross-process surface, so the host + * shows its own over the page and routes the actions back through [copy]/[cut]/[paste]/[selectAll], + * which run on this EditText's Editable (and so relay to the page through the normal edit path). + */ + var onRangeSelectionChanged: ((Boolean) -> Unit)? = null + private var hadRange = false + + /** Fired when the user edits text via the keyboard (not on programmatic page-state applies). The host + * hides the insertion handle while typing, the way Android does. */ + var onEdited: (() -> Unit)? = null + + fun copySelection(): Boolean = onTextContextMenuItem(android.R.id.copy) + + fun cutSelection(): Boolean = onTextContextMenuItem(android.R.id.cut) + + fun pasteClipboard(): Boolean = onTextContextMenuItem(android.R.id.paste) + + fun selectAllText(): Boolean = onTextContextMenuItem(android.R.id.selectAll) + /** A page field focused: configure the keyboard, seed the buffer, and raise the IME. */ fun onPageFocus(focus: ImeEvent.Focus) { configureFor(focus) - applyRemote(focus.text, focus.selStart, focus.selEnd) + // Focus the EditText BEFORE seeding text/selection. An EditText jumps its caret to the end when it + // gains focus; if we seed first, that end-position then overrides the seed and gets shipped to the + // page — so a tap mid-text lands the caret at the end of the field. Seeding AFTER focus makes the + // tap position the final state (the focus-induced end-position only schedules a flush that then + // coalesces to this seed, a no-op). requestFocus() imm.restartInput(this) + applyRemote(focus.text, focus.selStart, focus.selEnd) // Post the show so it runs after focus/attachment has settled (showSoftInput can no-op otherwise). post { if (hasFocus()) imm.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT) } } + // When the current selection first became a range, and how many of its collapse-abandonments we've + // re-asserted. Chrome abandons a selection *immediately* (~60ms); a deliberate user tap-to-collapse comes + // later — so we only re-assert within a short window of the range forming, bounded for safety. + private var rangeBecameAt = 0L + private var reassertCount = 0 + /** The page changed the field itself (its JS, autofill): mirror it without echoing back. */ fun onPageState(state: ImeEvent.State) { - if (text?.toString() == state.text && selectionStart == state.selStart && selectionEnd == state.selEnd) return + val curText = text?.toString() ?: "" + // Chrome can't present selection handles in the embedded surface, so it abandons a selection by + // collapsing the caret to one of the range's endpoints, right after it forms. We hold the + // authoritative selection here, so a collapse to an endpoint of our current range (same text) within + // the window is that abandonment, not a user action: re-assert our range instead of accepting it. + val collapsedToEndpoint = + selectionStart != selectionEnd && + state.selStart == state.selEnd && + state.text == curText && + (state.selStart == selectionStart || state.selStart == selectionEnd) + if (collapsedToEndpoint && reassertCount < MAX_REASSERT && + SystemClock.uptimeMillis() - rangeBecameAt < REASSERT_WINDOW_MS + ) { + reassertCount++ + // Keep the window alive across the fight: Chrome re-abandons the selection every ~600ms while the + // gesture is held, so each re-assert restarts the clock — bounded by reassertCount so a page that + // truly keeps the caret collapsed still wins. + rangeBecameAt = SystemClock.uptimeMillis() + lastSent = null // force a non-no-op flush so the range re-ships + flushState() + return + } + reassertCount = 0 + if (curText == state.text && selectionStart == state.selStart && selectionEnd == state.selEnd) return applyRemote(state.text, state.selStart, state.selEnd) } /** The page field blurred: drop the keyboard. */ fun onPageBlur() { + if (hadRange) { + hadRange = false + onRangeSelectionChanged?.invoke(false) + } clearFocus() imm.hideSoftInputFromWindow(windowToken, 0) } @@ -138,11 +201,18 @@ class RemoteImeView( selEnd: Int, ) { applyingRemote = true - setText(newText) + // Only replace the buffer when the text actually changed. A page that rewrites its field's + // selection on a timer (without changing the text) would otherwise force a full setText on every + // update — clearing the composing region and restarting the IME — which needlessly churns the + // keyboard and contends with the user's own typing. Reposition the cursor without touching the buffer. + if (text?.toString() != newText) setText(newText) val len = text?.length ?: 0 setSelection(selStart.coerceIn(0, len), selEnd.coerceIn(0, len)) applyingRemote = false lastSent = stateJson().toString() + // Start the abandonment window when a fresh range appears, so onPageState can tell Chrome's instant + // collapse from a later user tap-to-collapse. + if (selectionStart != selectionEnd) rangeBecameAt = SystemClock.uptimeMillis() } private fun schedule() { @@ -156,6 +226,11 @@ class RemoteImeView( selEnd: Int, ) { super.onSelectionChanged(selStart, selEnd) + val isRange = selStart != selEnd + if (isRange != hadRange) { + hadRange = isRange + onRangeSelectionChanged?.invoke(isRange) + } schedule() } @@ -237,4 +312,14 @@ class RemoteImeView( return result } } + + private companion object { + // Chrome re-abandons a held selection repeatedly; cover a long-press hold (each re-assert restarts the + // window) while still giving up if the page truly keeps re-collapsing forever. + private const val MAX_REASSERT = 12 + + // Wider than Chrome's ~600ms re-collapse interval so consecutive abandonments stay inside the window, + // yet short enough that a deliberate user tap-to-collapse (well after the gesture) is accepted. + private const val REASSERT_WINDOW_MS = 800L + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/EmbeddedNostrAppController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/EmbeddedNostrAppController.kt index 53cb90bfe9..a4198dd5db 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/EmbeddedNostrAppController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/EmbeddedNostrAppController.kt @@ -41,6 +41,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedImeBridge import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedLoadStatus import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedSurfaceController import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.ImeEvent +import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.parseSelectionGeometry import org.json.JSONObject import java.util.concurrent.atomic.AtomicLong @@ -201,6 +202,7 @@ class EmbeddedNostrAppController( text = o.optString("text", ""), selStart = o.optInt("selStart", 0), selEnd = o.optInt("selEnd", 0), + geometry = parseSelectionGeometry(o.optJSONObject("geom")), ) "ime.blur" -> ImeEvent.Blur "ime.state" -> @@ -208,6 +210,13 @@ class EmbeddedNostrAppController( text = o.optString("text", ""), selStart = o.optInt("selStart", 0), selEnd = o.optInt("selEnd", 0), + geometry = parseSelectionGeometry(o.optJSONObject("geom")), + ) + "ime.pagesel" -> + ImeEvent.PageSelection( + active = o.optBoolean("active", false), + text = o.optString("text", ""), + geometry = parseSelectionGeometry(o.optJSONObject("geom")), ) else -> null } diff --git a/commons/src/commonMain/composeResources/files/napplet/shim.js b/commons/src/commonMain/composeResources/files/napplet/shim.js index edba01974e..c08c9f2e31 100644 --- a/commons/src/commonMain/composeResources/files/napplet/shim.js +++ b/commons/src/commonMain/composeResources/files/napplet/shim.js @@ -191,6 +191,7 @@ if (IME_PROXY) (function(){ var el = null; // the focused editable element, or null var inComposition = false; + function perfNow(){ try { return performance.now(); } catch (_) { return 0; } } function isEditable(n){ if (!n) return false; @@ -205,7 +206,22 @@ } function isCE(n){ return !!(n && n.isContentEditable); } function valOf(n){ return isCE(n) ? n.textContent : (n.value || ''); } - function setVal(n, v){ if (isCE(n)) n.textContent = v; else n.value = v; } + // Controlled-input frameworks (React, Preact, …) install an INSTANCE-level `value` setter on the + // /