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 | 259 |
1 files changed, 259 insertions, 0 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 new file mode 100644 index 0000000..262af87 --- /dev/null +++ b/app/src/main/java/org/terst/feedreader/ui/ImageZoom.kt @@ -0,0 +1,259 @@ +package org.terst.feedreader.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.gestures.detectTransformGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +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 + +private const val IMAGE_ZOOM_DOUBLE_TAP_SCALE = 3f + +// An un-zoomed image has nothing to pan to, so a drag on one is read as "put this away" +// 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) + +/** Double-tap toggles: zoomed at all means "put it back," otherwise zoom in. */ +fun imageZoomDoubleTapScale(currentScale: Float): Float = + if (currentScale > 1f) 1f else IMAGE_ZOOM_DOUBLE_TAP_SCALE + +/** + * Keeps a pan within the part of the scaled image that's actually off-screen, so the image + * can never be dragged away from under the viewport and leave empty space. At fitted size + * nothing hangs off either edge, so there's nowhere to pan and the offset pins to 0. + */ +fun clampImagePanOffset(offset: Float, viewportSize: Float, contentSize: Float, scale: Float): Float { + val overhang = ((contentSize * scale - viewportSize) / 2f).coerceAtLeast(0f) + return offset.coerceIn(-overhang, overhang) +} + +/** + * Where the image actually ends up inside the viewport under ContentScale.Fit -- scaled up or + * down to touch the nearer pair of edges, letterboxed on the other axis. [clampImagePanOffset] + * needs this rather than the viewport: bounding a pan by the viewport would let a wide photo be + * dragged up into the empty black bar above it. + * + * Falls back to the viewport's own size while the image's dimensions are still unknown -- Coil + * can only report them once the image has decoded, and a pan has to be bounded by something in + * the meantime. + */ +fun fittedImageSize( + viewportWidth: Float, + viewportHeight: Float, + imageWidth: Float, + imageHeight: Float +): Pair<Float, Float> { + val known = { size: Float -> size.isFinite() && size > 0f } + if (!known(imageWidth) || !known(imageHeight)) return viewportWidth to viewportHeight + val fit = min(viewportWidth / imageWidth, viewportHeight / imageHeight) + return imageWidth * fit to imageHeight * fit +} + +/** + * 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, + * the close button, or a swipe down while un-zoomed. + * + * Coil's AsyncImage, the same loader the article list's thumbnails already use, so an image + * the user just scrolled past is usually served straight from its cache. The zoom itself is a + * graphicsLayer transform rather than a re-decode, which keeps a pinch smooth and costs + * nothing until it's used. + */ +@Composable +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 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) + + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black) + .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) + } + ) + } + .pointerInput(Unit) { + val dismissDistancePx = SWIPE_DISMISS_DISTANCE.toPx() + detectTransformGestures { _, pan, zoom, _ -> + val newScale = imageZoomScale(scale, zoom) + if (newScale > 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 + } else { + 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) + } + } + ) { + AsyncImage( + model = imageUrl, + contentDescription = null, + contentScale = ContentScale.Fit, + onSuccess = { intrinsic = it.painter.intrinsicSize.width to it.painter.intrinsicSize.height }, + modifier = Modifier + .fillMaxSize() + .graphicsLayer { + scaleX = scale + scaleY = scale + translationX = offsetX + translationY = offsetY + } + ) + IconButton(onClick = onDismiss, modifier = Modifier.align(Alignment.TopEnd).padding(8.dp)) { + Icon(Icons.Default.Close, contentDescription = "Close", tint = Color.White) + } + } + } +} |
