diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-09-29 07:12:48 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-09-29 07:12:48 +0000 |
| commit | a492e2def3dabe5e8de5ef4486b92859850503db (patch) | |
| tree | 5b8ca48f754b26fbbbbdbebda9686d0b758b9a31 /app/src/main/java/org/terst/feedreader/ui/ImageZoomLinks.kt | |
| parent | cf8ff865f7764953ded1b93c8ec42d4aff589e81 (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/ImageZoomLinks.kt')
| -rw-r--r-- | app/src/main/java/org/terst/feedreader/ui/ImageZoomLinks.kt | 106 |
1 files changed, 106 insertions, 0 deletions
diff --git a/app/src/main/java/org/terst/feedreader/ui/ImageZoomLinks.kt b/app/src/main/java/org/terst/feedreader/ui/ImageZoomLinks.kt new file mode 100644 index 0000000..b83b600 --- /dev/null +++ b/app/src/main/java/org/terst/feedreader/ui/ImageZoomLinks.kt @@ -0,0 +1,106 @@ +package org.terst.feedreader.ui + +// How a tap on an article image is noticed at all, and turned into a URL the viewer +// (ImageZoom.kt) can open. Split from the viewer itself because this half runs at +// HTML-assembly time, inside wrapReaderHtml -- it's reader-markup plumbing, not gestures. +// +// 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:" + +// 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 |
