mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-08 23:54:39 +00:00
fix(embed): IME typing + host-drawn text selection for embedded surfaces
Embedded WebView surfaces (:napplet process, SurfaceControlViewHost) can't
host the soft keyboard or present Chrome's own selection UI, so editing and
selection are relayed to the main process. This lands the working set of that
relay:
- shim.js: fix React-controlled input erase by writing through the native
HTMLInputElement/HTMLTextAreaElement value setter (so React's value tracker
stays in sync); make the host authoritative for selection re-assert (the
editable selectionchange handler only mirrors); report field/caret geometry
and page-text selection geometry; add pageExtend + caret coords (border-width
corrected) for drag-to-extend and the insertion handle.
- RemoteImeView: land caret where tapped on focus (requestFocus before applying
remote state); host-authoritative selection re-assert within a time window;
setText only when text actually changed; wire copy/cut/paste/select-all and
edit callbacks.
- EmbeddedTabLayer: stop resizing the surface on IME show (removes the ~1s
first-letter freeze); draw the selection overlay — toolbar, teardrop
selection handles, and the insertion (cursor) handle.
- EmbeddedImeBridge / Embedded{Napplet,Browser}Controller: carry selection +
caret geometry and the page-selection event across the Messenger channel.
Known limitation (not fixed here): after a field's page is opened in its own
full-screen activity and the user returns, the selection-highlight paint stays
off across all embedded surfaces. DOM selection, the toolbar, and copy still
work — only the native highlight is gone. This is a WebView/Chromium behavior
in off-window surfaces and is not reachable from the app layer.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
91df3c53ae
commit
baddd5b3bd
+9
@@ -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
|
||||
}
|
||||
|
||||
+56
@@ -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,
|
||||
)
|
||||
|
||||
+373
-5
@@ -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<String>) {
|
||||
// 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<String>) {
|
||||
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<SelectionGeometry?>(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<ImeEvent.PageSelection?>(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<String>) {
|
||||
).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<Offset?>(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<Offset?>(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<Pair<String, () -> 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),
|
||||
)
|
||||
}
|
||||
|
||||
+89
-4
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+9
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// <input>/<textarea> that records the last value they wrote, and then suppress their onChange whenever
|
||||
// the element's value already equals that recorded value. A plain `n.value = v` assignment goes through
|
||||
// that tracker, so our programmatic edit looks like a no-op to the framework: onChange never fires, its
|
||||
// state stays stale, and on the next render it reconciles the field straight back to the stale value —
|
||||
// wiping what we just typed. Writing through the NATIVE prototype setter sets the real value without
|
||||
// touching the tracker, so the framework's input handler sees value != tracked, detects the change, and
|
||||
// commits it. Identical to `n.value = v` for plain (non-framework) pages.
|
||||
var nativeInputValueSet = (function(){ try { return Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set; } catch (_) { return null; } })();
|
||||
var nativeAreaValueSet = (function(){ try { return Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set; } catch (_) { return null; } })();
|
||||
function setVal(n, v){
|
||||
if (isCE(n)) { n.textContent = v; return; }
|
||||
var set = (typeof HTMLTextAreaElement !== 'undefined' && n instanceof HTMLTextAreaElement) ? nativeAreaValueSet : nativeInputValueSet;
|
||||
if (set) { try { set.call(n, v); return; } catch (_) {} }
|
||||
n.value = v;
|
||||
}
|
||||
|
||||
// --- contenteditable selection/replacement, mapped through char offsets into textContent ---
|
||||
// We can't use setSelectionRange/value on a contenteditable root; instead we map a char offset
|
||||
@@ -264,13 +280,78 @@
|
||||
if (isCE(n)) ceSetSel(n, s, e);
|
||||
else { try { n.setSelectionRange(s, e); } catch (_) {} }
|
||||
}
|
||||
// The DOM exposes no caret/selection rect for a position inside an <input>/<textarea>, so we mirror the
|
||||
// field into a hidden div (same font/padding/wrapping) and measure where a marker span lands — the
|
||||
// well-known "textarea-caret-position" technique. Used to place the insertion handle (and drag it).
|
||||
var CARET_PROPS = ['direction','boxSizing','width','height','overflowX','overflowY','borderTopWidth','borderRightWidth','borderBottomWidth','borderLeftWidth','paddingTop','paddingRight','paddingBottom','paddingLeft','fontStyle','fontVariant','fontWeight','fontStretch','fontSize','fontSizeAdjust','lineHeight','fontFamily','textAlign','textTransform','textIndent','textDecoration','letterSpacing','wordSpacing','tabSize','MozTabSize'];
|
||||
function caretCoords(n, position){
|
||||
try {
|
||||
var isInput = (n.nodeName || '').toUpperCase() === 'INPUT';
|
||||
var computed = window.getComputedStyle(n);
|
||||
var div = document.createElement('div');
|
||||
var s = div.style;
|
||||
s.position = 'absolute'; s.visibility = 'hidden'; s.whiteSpace = isInput ? 'nowrap' : 'pre-wrap'; s.wordWrap = 'break-word'; s.overflow = 'hidden';
|
||||
for (var i = 0; i < CARET_PROPS.length; i++) { s[CARET_PROPS[i]] = computed[CARET_PROPS[i]]; }
|
||||
var val = valOf(n);
|
||||
div.textContent = val.substring(0, position);
|
||||
if (isInput) div.textContent = div.textContent.replace(/\s/g, ' ');
|
||||
var span = document.createElement('span');
|
||||
span.textContent = val.substring(position) || '.';
|
||||
div.appendChild(span);
|
||||
document.body.appendChild(div);
|
||||
var caretL = span.offsetLeft, caretT = span.offsetTop;
|
||||
var lineHeight = parseInt(computed.lineHeight) || parseInt(computed.fontSize) || 16;
|
||||
document.body.removeChild(div);
|
||||
// offsetLeft/Top are measured from the mirror's *inner* (padding) edge, but getBoundingClientRect is the
|
||||
// *outer* border box — so add the field's border widths to land on the real caret.
|
||||
var bl = parseFloat(computed.borderLeftWidth) || 0, bt = parseFloat(computed.borderTopWidth) || 0;
|
||||
var rect = n.getBoundingClientRect();
|
||||
var x = rect.left + bl + caretL - n.scrollLeft;
|
||||
var top = rect.top + bt + caretT - n.scrollTop;
|
||||
return { x: x, top: top, bottom: top + lineHeight };
|
||||
} catch (_) { return null; }
|
||||
}
|
||||
// Inverse: the char offset whose caret is nearest the CSS-px point (x,y). Binary search in reading order
|
||||
// (offset increases left-to-right, top-to-bottom), so a handle drag maps back to a cursor position. Y is
|
||||
// first clamped into the field's text rows, so dragging the handle (which sits below the line) or off the
|
||||
// field keeps the cursor on the nearest line and lets X drive the column — like Android.
|
||||
function offsetFromPoint(n, x, y){
|
||||
var len = valOf(n).length;
|
||||
var first = caretCoords(n, 0), last = caretCoords(n, len);
|
||||
if (first && y < first.top) y = (first.top + first.bottom) / 2;
|
||||
else if (last && y > last.bottom) y = (last.top + last.bottom) / 2;
|
||||
var lo = 0, hi = len;
|
||||
while (lo < hi) {
|
||||
var mid = (lo + hi) >> 1, c = caretCoords(n, mid);
|
||||
if (!c) break;
|
||||
if (y < c.top) hi = mid;
|
||||
else if (y > c.bottom) lo = mid + 1;
|
||||
else if (x < c.x) hi = mid;
|
||||
else lo = mid + 1;
|
||||
}
|
||||
return lo;
|
||||
}
|
||||
// The focused field's bounding box in CSS px (toolbar anchor). When the selection is a bare caret it also
|
||||
// carries the caret rect (cx/ct/cb) so the host can show a draggable insertion handle.
|
||||
function fieldGeom(n){
|
||||
try {
|
||||
var b = n.getBoundingClientRect();
|
||||
var g = { l: b.left, t: b.top, r: b.right, b: b.bottom, sx: b.left, sb: b.bottom, ex: b.right, eb: b.bottom, vw: window.innerWidth };
|
||||
var sel = selOf(n);
|
||||
if (sel[0] === sel[1] && !isCE(n)) {
|
||||
var c = caretCoords(n, sel[0]);
|
||||
if (c) { g.cx = c.x; g.ct = c.top; g.cb = c.bottom; }
|
||||
}
|
||||
return g;
|
||||
} catch (_) { return null; }
|
||||
}
|
||||
function focusInfo(n){
|
||||
var t = (n.tagName || '').toUpperCase();
|
||||
var multiline = isCE(n) || t === 'TEXTAREA';
|
||||
var inputType = isCE(n) ? 'text' : (t === 'TEXTAREA' ? 'textarea' : (n.type || 'text').toLowerCase());
|
||||
var sel = selOf(n);
|
||||
return { type:'ime.focus', inputType: inputType, enterKeyHint: (n.enterKeyHint || ''),
|
||||
multiline: multiline, text: valOf(n), selStart: sel[0], selEnd: sel[1] };
|
||||
multiline: multiline, text: valOf(n), selStart: sel[0], selEnd: sel[1], geom: fieldGeom(n) };
|
||||
}
|
||||
// Last selection we either applied (applyState) or already reported, so the asynchronous
|
||||
// selectionchange our own setSel triggers doesn't echo back to the host as a fresh edit.
|
||||
@@ -280,7 +361,7 @@
|
||||
if (!el) return;
|
||||
var sel = selOf(el);
|
||||
lastSel = sel;
|
||||
send({ type:'ime.state', text: valOf(el), selStart: sel[0], selEnd: sel[1] });
|
||||
send({ type:'ime.state', text: valOf(el), selStart: sel[0], selEnd: sel[1], geom: fieldGeom(el) });
|
||||
}
|
||||
|
||||
document.addEventListener('focusin', function(e){
|
||||
@@ -296,6 +377,9 @@
|
||||
}, true);
|
||||
// The page (its own JS, autofill) changed the field: resync the host keyboard's view of it.
|
||||
document.addEventListener('input', function(e){ if (e.target === el && !el.__nappletIme) reportState(); }, true);
|
||||
// Just mirror selection changes inside the focused editable to the host. The host owns the authoritative
|
||||
// selection, so it (not the shim) detects and re-asserts the collapse-to-endpoint that Chrome does when it
|
||||
// abandons a selection it can't present handles for in the embedded surface — see RemoteImeView.onPageState.
|
||||
document.addEventListener('selectionchange', function(){
|
||||
if (!el || el.__nappletIme) return;
|
||||
if (sameSel(selOf(el), lastSel)) return; // our own applyState/setSel echoing back
|
||||
@@ -366,9 +450,80 @@
|
||||
} finally { n.__nappletIme = false; lastSel = selOf(n); }
|
||||
}
|
||||
|
||||
// --- Page (non-editable) text selection re-hosting ---
|
||||
// Chrome can't present its selection handles/toolbar in the cross-process embedded surface, so a
|
||||
// long-press on ordinary page text selects a word and then ~60ms later abandons (collapses) it, the
|
||||
// same way it does inside inputs. Mirror the document selection: re-assert it when it collapses right
|
||||
// after forming, and report the selected text so the host can show its own Copy bar over the page.
|
||||
var pageSelText = '', lastPageRange = null, lastPageAt = -1, pageReasserting = false;
|
||||
// Selection geometry in CSS px (viewport coords). The host maps these to screen px (scale = surface
|
||||
// width / vw) to draw the toolbar above the selection and a handle at each end. l/t/r/b is the bounding
|
||||
// box; (sx,sb) the start-caret foot, (ex,eb) the end-caret foot; vw lets the host derive the scale.
|
||||
function pageGeom(r){
|
||||
try {
|
||||
var b = r.getBoundingClientRect();
|
||||
var sr = r.cloneRange(); sr.collapse(true); var s = sr.getBoundingClientRect();
|
||||
var er = r.cloneRange(); er.collapse(false); var e = er.getBoundingClientRect();
|
||||
return { l: b.left, t: b.top, r: b.right, b: b.bottom, sx: s.left, sb: s.bottom, ex: e.left, eb: e.bottom, vw: window.innerWidth };
|
||||
} catch (_) { return null; }
|
||||
}
|
||||
function sendPageSel(active, r){
|
||||
var text = active ? String(window.getSelection()) : '';
|
||||
pageSelText = text;
|
||||
send({ type: 'ime.pagesel', active: active, text: text, geom: active && r ? pageGeom(r) : null });
|
||||
}
|
||||
document.addEventListener('selectionchange', function(){
|
||||
if (el || pageReasserting) return; // selections inside an editable are handled above
|
||||
var s = window.getSelection();
|
||||
if (s && s.rangeCount && !s.isCollapsed) {
|
||||
var r = s.getRangeAt(0);
|
||||
lastPageRange = r.cloneRange(); lastPageAt = perfNow();
|
||||
sendPageSel(true, r);
|
||||
} else if (lastPageRange && (perfNow() - lastPageAt) < 400) {
|
||||
pageReasserting = true;
|
||||
try { s.removeAllRanges(); s.addRange(lastPageRange); } catch (_) {}
|
||||
pageReasserting = false;
|
||||
lastPageAt = perfNow();
|
||||
} else if (pageSelText) {
|
||||
lastPageRange = null;
|
||||
sendPageSel(false, null);
|
||||
}
|
||||
}, true);
|
||||
// Host drag of a selection handle: move the dragged edge to the text position under (x,y) CSS px,
|
||||
// keeping the opposite edge anchored. setBaseAndExtent tolerates either drag direction.
|
||||
function pageExtend(edge, x, y){
|
||||
try {
|
||||
var pt = document.caretRangeFromPoint && document.caretRangeFromPoint(x, y);
|
||||
var s = window.getSelection();
|
||||
if (!pt || !s.rangeCount) return;
|
||||
var cur = s.getRangeAt(0);
|
||||
var aN, aO;
|
||||
if (edge === 'start') { aN = cur.endContainer; aO = cur.endOffset; } else { aN = cur.startContainer; aO = cur.startOffset; }
|
||||
pageReasserting = true;
|
||||
s.setBaseAndExtent(aN, aO, pt.startContainer, pt.startOffset);
|
||||
pageReasserting = false;
|
||||
if (!s.isCollapsed) {
|
||||
var nr = s.getRangeAt(0);
|
||||
lastPageRange = nr.cloneRange(); lastPageAt = perfNow();
|
||||
sendPageSel(true, nr);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
window.__nappletImeHandle = function(msg){
|
||||
if (msg.type === 'ime.set') applyState(msg);
|
||||
else if (msg.type === 'ime.action') enter(el);
|
||||
else if (msg.type === 'ime.pageextend') pageExtend(msg.edge, msg.x, msg.y);
|
||||
else if (msg.type === 'ime.caretmove') {
|
||||
if (el && !isCE(el)) {
|
||||
var off = offsetFromPoint(el, msg.x, msg.y);
|
||||
el.__nappletIme = true;
|
||||
setSel(el, off, off);
|
||||
el.__nappletIme = false;
|
||||
lastSel = selOf(el);
|
||||
reportState();
|
||||
}
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user