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 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 closing , or an -- in document order, so a single pass // can tell which images are already inside a link. `, //
etc., since \b requires a non-word character right after the "a". private val anchorOrImageTagRegex = Regex("""]*>||]*>""", 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(" if (anchorDepth > 0) anchorDepth-- text.startsWith(" 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("").append(text).append("") 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 { 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) } } } }