summaryrefslogtreecommitdiff
path: root/app/src/main/java/org/terst/feedreader/ui/ImageZoom.kt
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-09-29 07:12:48 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-09-29 07:12:48 +0000
commita492e2def3dabe5e8de5ef4486b92859850503db (patch)
tree5b8ca48f754b26fbbbbdbebda9686d0b758b9a31 /app/src/main/java/org/terst/feedreader/ui/ImageZoom.kt
parentcf8ff865f7764953ded1b93c8ec42d4aff589e81 (diff)
Reflow reader text live while pinching, and let a zoomed image pan
Pinching in the reader used to scale the whole WebView like a photo and snap back to the real font size on release. It now drives WebSettings.textZoom as the fingers move, so the text itself resizes and reflows during the gesture with nothing to snap back from; images and margins stay put. The reload at the new saved size is what returns textZoom to 100%, so the two never overlap -- and loading reader HTML now goes through ReaderWebView.loadReaderHtml, which does both together, so a future load site can't forget the reset. Reader view for a tapped link now honors the saved font size and the same pinch gesture, instead of always rendering at the 18px default. A zoomed image can be panned again. The viewer computed its pan bounds from values captured when the dialog first composed -- an empty viewport around a not-yet-decoded image -- because a pointerInput(Unit) block keeps the lambda it was created with. Those bounds allowed exactly one offset, 0, so zooming worked and dragging did nothing. The whole pinch/pan step is now one pure function the gesture callback calls with values it reads fresh each time.
Diffstat (limited to 'app/src/main/java/org/terst/feedreader/ui/ImageZoom.kt')
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/ImageZoom.kt179
1 files changed, 59 insertions, 120 deletions
diff --git a/app/src/main/java/org/terst/feedreader/ui/ImageZoom.kt b/app/src/main/java/org/terst/feedreader/ui/ImageZoom.kt
index 262af87..bf03382 100644
--- a/app/src/main/java/org/terst/feedreader/ui/ImageZoom.kt
+++ b/app/src/main/java/org/terst/feedreader/ui/ImageZoom.kt
@@ -29,19 +29,6 @@ import androidx.compose.ui.window.DialogProperties
import coil.compose.AsyncImage
import kotlin.math.min
-// Article images live inside the reader's WebView, which runs with JavaScript off (see
-// ReaderWebView) -- so there's no in-page script that could notice a tap on an <img> and
-// report it out. And WebView's own built-in zoom (setSupportZoom/builtInZoomControls) isn't
-// an option either: pinching in the reader is already taken, deliberately, by the
-// reader-font-size gesture, and page zoom would scale the whole article's layout rather than
-// giving an image the full screen.
-//
-// So tap detection is done the one way that works with JS off: every image the reader renders
-// is wrapped in a link to a scheme nothing can actually navigate to, which WebView then hands
-// to shouldOverrideUrlLoading -- the same interception point that already routes ordinary
-// links and YouTube thumbnails. The callback opens ImageZoomDialog instead of following it.
-private const val IMAGE_ZOOM_SCHEME = "feedreader-image:"
-
/** Fitted (1x) up to this; past ~6x an article image is nothing but its own pixels. */
const val IMAGE_ZOOM_MAX_SCALE = 6f
@@ -51,94 +38,6 @@ private const val IMAGE_ZOOM_DOUBLE_TAP_SCALE = 3f
// instead. Far enough that an ordinary sloppy tap can't trigger it by accident.
private val SWIPE_DISMISS_DISTANCE = 120.dp
-// Matches an opening <a>, a closing </a>, or an <img> -- in document order, so a single pass
-// can tell which images are already inside a link. `<a\b` deliberately doesn't match <abbr>,
-// <article> etc., since \b requires a non-word character right after the "a".
-private val anchorOrImageTagRegex = Regex("""<a\b[^>]*>|</a\s*>|<img\b[^>]*>""", RegexOption.IGNORE_CASE)
-
-private val imgSrcRegex = Regex("""\bsrc\s*=\s*(?:"([^"]*)"|'([^']*)')""", RegexOption.IGNORE_CASE)
-
-private val imageFileExtensions = listOf(".jpg", ".jpeg", ".png", ".gif", ".webp", ".avif", ".bmp", ".heic", ".heif")
-
-/**
- * Wraps every article image that isn't already inside a link in a zoom link, so tapping it
- * reaches [zoomableImageTarget] via the reader WebView's shouldOverrideUrlLoading.
- *
- * Images that are already inside a link are left exactly as they were -- their link is doing
- * something already (ReaderExtractor's synthesized YouTube thumbnail is precisely this shape,
- * and wrapping it would swallow the tap that plays the video). Those are still zoomable when
- * the link points at an image file, which [zoomableImageTarget] handles on the other side.
- */
-fun linkifyImagesForZoom(html: String): String = buildString {
- var copiedUpTo = 0
- var anchorDepth = 0
- for (tag in anchorOrImageTagRegex.findAll(html)) {
- val text = tag.value
- when {
- text.startsWith("</", ignoreCase = true) -> if (anchorDepth > 0) anchorDepth--
- text.startsWith("<a", ignoreCase = true) -> anchorDepth++
- anchorDepth == 0 -> {
- val src = imgSrcRegex.find(text)?.let { it.groupValues[1].ifEmpty { it.groupValues[2] } }
- val href = src?.let { zoomHrefForSrc(it) }
- if (href != null) {
- append(html, copiedUpTo, tag.range.first)
- append("<a href=\"").append(href).append("\">").append(text).append("</a>")
- copiedUpTo = tag.range.last + 1
- }
- }
- }
- }
- append(html, copiedUpTo, html.length)
-}
-
-private fun zoomHrefForSrc(src: String): String? {
- val trimmed = src.trim()
- val absolute = when {
- // Protocol-relative (//host/path) is common in feed markup and resolves fine in the
- // page itself, but our scheme-prefixed href has to carry a complete URL on its own.
- trimmed.startsWith("//") -> "https:$trimmed"
- trimmed.startsWith("http://", ignoreCase = true) || trimmed.startsWith("https://", ignoreCase = true) -> trimmed
- // Anything else (site-relative, data:, javascript:) is skipped rather than guessed at:
- // a relative src resolves against the WebView's base URL, which a custom-scheme href
- // can't reproduce, so wrapping it would only build a dead link.
- else -> null
- } ?: return null
- // The src came out of a quoted attribute and goes straight back into one, already escaped
- // as the source wrote it. A raw quote or angle bracket could only arrive via a
- // single-quoted attribute, and would break out of the href -- drop those instead.
- return if (absolute.any { it == '"' || it == '<' || it == '>' }) null else IMAGE_ZOOM_SCHEME + absolute
-}
-
-/**
- * The image a tapped URL should open full-screen, or null if it isn't an image at all.
- *
- * Covers both halves of the problem: links [linkifyImagesForZoom] built, and links the article
- * itself already had that point straight at an image file (WordPress's default "link to media
- * file" wraps article images in exactly that, which used to send a tap off to LinkReaderScreen
- * to try and run Readability over a JPEG).
- */
-fun zoomableImageTarget(url: String): String? = when {
- url.startsWith(IMAGE_ZOOM_SCHEME, ignoreCase = true) ->
- url.substring(IMAGE_ZOOM_SCHEME.length).takeIf { it.isNotBlank() }
- looksLikeImageUrl(url) -> url
- else -> null
-}
-
-// Extension-sniffing, not a content-type check -- there's no response to inspect at the point
-// a link is tapped. SVG is deliberately not in the list: Coil's default decoders can't render
-// one, so a viewer opened on it would just show an empty black screen.
-private fun looksLikeImageUrl(url: String): Boolean {
- val path = url.substringBefore('#').substringBefore('?').lowercase()
- return imageFileExtensions.any { path.endsWith(it) }
-}
-
-/**
- * The URL to show the user for a link -- the zoom scheme is an internal detail, so a
- * long-press on a zoom-wrapped image offers actions on the real image URL.
- */
-fun withoutImageZoomScheme(url: String): String =
- if (url.startsWith(IMAGE_ZOOM_SCHEME, ignoreCase = true)) url.substring(IMAGE_ZOOM_SCHEME.length) else url
-
/** Applies a pinch to the current zoom level, clamped to fitted-size..[IMAGE_ZOOM_MAX_SCALE]. */
fun imageZoomScale(currentScale: Float, gestureZoom: Float): Float =
(currentScale * gestureZoom).coerceIn(1f, IMAGE_ZOOM_MAX_SCALE)
@@ -179,6 +78,50 @@ fun fittedImageSize(
return imageWidth * fit to imageHeight * fit
}
+/** How the viewer is currently showing the image: zoom level, plus where it's been dragged to. */
+data class ImageZoomState(val scale: Float = 1f, val offsetX: Float = 0f, val offsetY: Float = 0f)
+
+/**
+ * The viewer's state after one step of a transform gesture -- the whole pinch-and-pan
+ * calculation in one place, rather than spread across the gesture callback.
+ *
+ * It lives here, as a function of nothing but its arguments, because the callback can't safely
+ * close over anything computed during composition: a `pointerInput(Unit)` block is created once
+ * and keeps running with that first lambda, so a pan bound computed outside it stayed frozen at
+ * its first-composition value -- a zero-size viewport holding a not-yet-decoded image, whose
+ * only valid offset is 0. That's the whole reason a zoomed image wouldn't pan. Everything the
+ * math needs is a parameter, so the callback has to read it fresh each time.
+ */
+fun imageZoomTransform(
+ state: ImageZoomState,
+ viewportSize: Pair<Float, Float>,
+ intrinsicSize: Pair<Float, Float>,
+ pan: Pair<Float, Float>,
+ gestureZoom: Float
+): ImageZoomState {
+ val scale = imageZoomScale(state.scale, gestureZoom)
+ val (fittedWidth, fittedHeight) =
+ fittedImageSize(viewportSize.first, viewportSize.second, intrinsicSize.first, intrinsicSize.second)
+ return ImageZoomState(
+ scale = scale,
+ offsetX = clampImagePanOffset(state.offsetX + pan.first, viewportSize.first, fittedWidth, scale),
+ offsetY = clampImagePanOffset(state.offsetY + pan.second, viewportSize.second, fittedHeight, scale)
+ )
+}
+
+/** Double-tap: toggle the zoom level, re-clamping the existing pan to whatever it becomes. */
+fun imageZoomDoubleTap(
+ state: ImageZoomState,
+ viewportSize: Pair<Float, Float>,
+ intrinsicSize: Pair<Float, Float>
+): ImageZoomState = imageZoomTransform(
+ state = state,
+ viewportSize = viewportSize,
+ intrinsicSize = intrinsicSize,
+ pan = 0f to 0f,
+ gestureZoom = imageZoomDoubleTapScale(state.scale) / state.scale
+)
+
/**
* Full-screen viewer for a tapped article image: pinch to zoom, drag to pan while zoomed,
* double-tap to toggle between fitted and zoomed, and dismiss with the system back gesture,
@@ -194,16 +137,18 @@ fun ImageZoomDialog(imageUrl: String, onDismiss: () -> Unit) {
// usePlatformDefaultWidth would cap this at the platform's narrow AlertDialog width --
// same reason YouTubePlayerDialog opts out. A photo viewer wants the whole screen.
Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false)) {
- var scale by remember { mutableStateOf(1f) }
- var offsetX by remember { mutableStateOf(0f) }
- var offsetY by remember { mutableStateOf(0f) }
+ var zoom by remember(imageUrl) { mutableStateOf(ImageZoomState()) }
var viewport by remember { mutableStateOf(IntSize.Zero) }
var dismissDragY by remember { mutableStateOf(0f) }
// The image's own decoded dimensions, reported by Coil once it has one to report.
// Panning is bounded by where the image actually is, not by the whole viewport --
// ContentScale.Fit letterboxes anything that isn't exactly the screen's aspect ratio.
var intrinsic by remember(imageUrl) { mutableStateOf(0f to 0f) }
- val fitted = fittedImageSize(viewport.width.toFloat(), viewport.height.toFloat(), intrinsic.first, intrinsic.second)
+ // Deliberately a function called from inside the gesture callbacks, not a value computed
+ // out here: see imageZoomTransform's comment -- the pointerInput blocks below are created
+ // once and keep the lambda they were created with, so anything computed at composition
+ // time would still be reading an empty viewport long after the image had been laid out.
+ fun viewportSize() = viewport.width.toFloat() to viewport.height.toFloat()
Box(
modifier = Modifier
@@ -212,18 +157,14 @@ fun ImageZoomDialog(imageUrl: String, onDismiss: () -> Unit) {
.onSizeChanged { viewport = it }
.pointerInput(Unit) {
detectTapGestures(
- onDoubleTap = {
- scale = imageZoomDoubleTapScale(scale)
- offsetX = clampImagePanOffset(offsetX, viewport.width.toFloat(), fitted.first, scale)
- offsetY = clampImagePanOffset(offsetY, viewport.height.toFloat(), fitted.second, scale)
- }
+ onDoubleTap = { zoom = imageZoomDoubleTap(zoom, viewportSize(), intrinsic) }
)
}
.pointerInput(Unit) {
val dismissDistancePx = SWIPE_DISMISS_DISTANCE.toPx()
- detectTransformGestures { _, pan, zoom, _ ->
- val newScale = imageZoomScale(scale, zoom)
- if (newScale > 1f) {
+ detectTransformGestures { _, pan, gestureZoom, _ ->
+ val next = imageZoomTransform(zoom, viewportSize(), intrinsic, pan.x to pan.y, gestureZoom)
+ if (next.scale > 1f) {
// Zoomed in: a drag is a pan, and can't also be a dismiss -- otherwise
// panning down to the bottom of a tall image would close the viewer.
dismissDragY = 0f
@@ -231,9 +172,7 @@ fun ImageZoomDialog(imageUrl: String, onDismiss: () -> Unit) {
dismissDragY += pan.y
if (dismissDragY > dismissDistancePx) onDismiss()
}
- scale = newScale
- offsetX = clampImagePanOffset(offsetX + pan.x, viewport.width.toFloat(), fitted.first, newScale)
- offsetY = clampImagePanOffset(offsetY + pan.y, viewport.height.toFloat(), fitted.second, newScale)
+ zoom = next
}
}
) {
@@ -245,10 +184,10 @@ fun ImageZoomDialog(imageUrl: String, onDismiss: () -> Unit) {
modifier = Modifier
.fillMaxSize()
.graphicsLayer {
- scaleX = scale
- scaleY = scale
- translationX = offsetX
- translationY = offsetY
+ scaleX = zoom.scale
+ scaleY = zoom.scale
+ translationX = zoom.offsetX
+ translationY = zoom.offsetY
}
)
IconButton(onClick = onDismiss, modifier = Modifier.align(Alignment.TopEnd).padding(8.dp)) {