diff options
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.kt | 179 |
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)) { |
