summaryrefslogtreecommitdiff
path: root/app/src/main/java/org/terst/feedreader/ui/ImageZoom.kt
diff options
context:
space:
mode:
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)) {