summaryrefslogtreecommitdiff
path: root/app/src/main/java/org/terst/feedreader
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-09-29 07:12:48 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-09-29 07:12:48 +0000
commita492e2def3dabe5e8de5ef4486b92859850503db (patch)
tree5b8ca48f754b26fbbbbdbebda9686d0b758b9a31 /app/src/main/java/org/terst/feedreader
parentcf8ff865f7764953ded1b93c8ec42d4aff589e81 (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')
-rw-r--r--app/src/main/java/org/terst/feedreader/data/Settings.kt18
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/ImageZoom.kt179
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/ImageZoomLinks.kt106
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/LinkReaderScreen.kt42
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt4
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/ReaderWebView.kt69
6 files changed, 269 insertions, 149 deletions
diff --git a/app/src/main/java/org/terst/feedreader/data/Settings.kt b/app/src/main/java/org/terst/feedreader/data/Settings.kt
index ebeba6c..18c5f9d 100644
--- a/app/src/main/java/org/terst/feedreader/data/Settings.kt
+++ b/app/src/main/java/org/terst/feedreader/data/Settings.kt
@@ -39,12 +39,30 @@ private const val READER_FONT_SIZE_MIN_PX = 14
private const val READER_FONT_SIZE_MAX_PX = 28
val READER_FONT_SIZE_RANGE_PX = READER_FONT_SIZE_MIN_PX..READER_FONT_SIZE_MAX_PX
+/** WebView's textZoom baseline: 100% is "render the CSS font sizes exactly as written." */
+const val UNZOOMED_TEXT_ZOOM_PERCENT = 100
+
// Pure so the pinch-to-zoom math is testable without a WebView/gesture detector in the loop --
// ReaderWebView.kt calls this once per gesture (at pinch-end, with the cumulative scale factor
// accumulated across the whole gesture), not per touch-move frame.
fun pinchZoomFontSizePx(basePx: Int, scaleFactor: Float): Int =
(basePx * scaleFactor).roundToInt().coerceIn(READER_FONT_SIZE_RANGE_PX.first, READER_FONT_SIZE_RANGE_PX.last)
+/**
+ * The WebView textZoom percentage that makes text laid out at [renderedPx] read as [targetPx].
+ *
+ * This is what makes a pinch reflow live instead of scaling the whole page and snapping back:
+ * WebView's textZoom re-lays-out the text (and only the text -- images and padding stay put)
+ * the moment it changes, with no reload and no JavaScript.
+ *
+ * [renderedPx] is the size the currently-loaded HTML was actually written at, which isn't always
+ * the saved font size -- a second pinch can start before the reload from the first one has
+ * landed. Measuring against the saved size in that window would shrink the text back to the
+ * old layout for a frame before growing again.
+ */
+fun readerTextZoomPercent(renderedPx: Int, targetPx: Int): Int =
+ (targetPx * UNZOOMED_TEXT_ZOOM_PERCENT / renderedPx.coerceAtLeast(1).toFloat()).roundToInt()
+
/** Stash connection settings, configured on the Settings screen. Both blank until set. */
class Settings(private val context: Context) {
val stashBaseUrl: Flow<String> =
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)) {
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
diff --git a/app/src/main/java/org/terst/feedreader/ui/LinkReaderScreen.kt b/app/src/main/java/org/terst/feedreader/ui/LinkReaderScreen.kt
index b8956f2..4ed0722 100644
--- a/app/src/main/java/org/terst/feedreader/ui/LinkReaderScreen.kt
+++ b/app/src/main/java/org/terst/feedreader/ui/LinkReaderScreen.kt
@@ -31,10 +31,12 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -45,6 +47,8 @@ import androidx.compose.ui.viewinterop.AndroidView
import kotlinx.coroutines.launch
import org.terst.feedreader.data.AppDatabase
import org.terst.feedreader.data.ArticleEntity
+import org.terst.feedreader.data.DEFAULT_READER_FONT_SIZE_PX
+import org.terst.feedreader.data.Settings
import org.terst.feedreader.data.ensureStandaloneArticle
import org.terst.feedreader.data.saveArticleToStash
import org.terst.feedreader.data.urlToArticleId
@@ -58,7 +62,9 @@ import java.net.URLEncoder
* dropping into the system browser. Brought to parity with the main article reader:
* save locally / save to Stash (lazily creates a standalone ArticleEntity, feedId=null,
* keyed by a hash of the URL -- same scheme ShareReceiverActivity uses, so sharing the
- * same link later lands on the same row instead of duplicating it), a reader-mode
+ * same link later lands on the same row instead of duplicating it), the saved reader font
+ * size and its pinch-to-resize gesture (a link read here is the same reading surface as an
+ * article, so it shouldn't ignore a size the article view respects), a reader-mode
* toggle (falls back to the live page, JS on, when the user wants the original instead
* of the extracted view or extraction failed), and the same login/search-alternate menu.
*/
@@ -68,9 +74,11 @@ import java.net.URLEncoder
fun LinkReaderScreen(url: String, onBack: () -> Unit, onOpenLink: (String) -> Unit, onOpenBrowser: (String) -> Unit) {
val context = LocalContext.current
val db = remember { AppDatabase.get(context) }
+ val settings = remember { Settings(context) }
val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
val articleId = remember(url) { urlToArticleId(url) }
+ val fontSizePx by settings.readerFontSizePx.collectAsState(initial = DEFAULT_READER_FONT_SIZE_PX)
var article by remember(url) { mutableStateOf<ArticleEntity?>(null) }
var extractedTitle by remember(url) { mutableStateOf<String?>(null) }
@@ -229,7 +237,12 @@ fun LinkReaderScreen(url: String, onBack: () -> Unit, onOpenLink: (String) -> Un
val currentExtractedContent = extractedContent
when {
isReaderMode && currentExtractedContent != null -> {
- val loadedContent = remember(url) { mutableStateOf<String?>(null) }
+ // Keyed on font size as well as content, exactly like ReaderScreen: a link
+ // opened in reader view is the same reading surface as an article, so it
+ // honors the same saved size and the same pinch-to-resize gesture, and picks
+ // up a size changed elsewhere while this screen stayed open.
+ val loadedKey = remember(url) { mutableStateOf<Pair<String?, Int>?>(null) }
+ val latestFontSizePx by rememberUpdatedState(fontSizePx)
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { ctx ->
@@ -239,17 +252,21 @@ fun LinkReaderScreen(url: String, onBack: () -> Unit, onOpenLink: (String) -> Un
onOpenLink = onOpenLink,
onLongPressLink = { longPressedLink = it },
onYouTubeVideo = { youtubeVideoId = it },
- onImageTap = { zoomedImageUrl = it }
+ onImageTap = { zoomedImageUrl = it },
+ currentFontSizePx = { latestFontSizePx },
+ onFontSizeChanged = { newSizePx -> scope.launch { settings.setReaderFontSizePx(newSizePx) } }
)
).apply {
- loadDataWithBaseURL(url, wrapReaderHtml(currentExtractedContent, extractedTitle), "text/html", "UTF-8", null)
- loadedContent.value = currentExtractedContent
+ val html = wrapReaderHtml(currentExtractedContent, extractedTitle, fontSizePx = fontSizePx)
+ loadReaderHtml(url, html, fontSizePx)
+ loadedKey.value = currentExtractedContent to fontSizePx
}
},
update = { webView ->
- if (loadedContent.value != currentExtractedContent) {
- webView.loadDataWithBaseURL(url, wrapReaderHtml(currentExtractedContent, extractedTitle), "text/html", "UTF-8", null)
- loadedContent.value = currentExtractedContent
+ if (loadedKey.value != currentExtractedContent to fontSizePx) {
+ val html = wrapReaderHtml(currentExtractedContent, extractedTitle, fontSizePx = fontSizePx)
+ webView.loadReaderHtml(url, html, fontSizePx)
+ loadedKey.value = currentExtractedContent to fontSizePx
}
},
onRelease = { webView -> webView.destroy() }
@@ -276,8 +293,13 @@ fun LinkReaderScreen(url: String, onBack: () -> Unit, onOpenLink: (String) -> Un
modifier = Modifier.fillMaxSize(),
factory = { ctx ->
WebView(ctx).apply {
- settings.javaScriptEnabled = true
- settings.domStorageEnabled = true
+ // Qualified: `settings` unqualified would now reach this
+ // screen's app Settings (reader font size), which shadows
+ // the WebView's own WebSettings inside this apply block.
+ // JS on here is this screen's documented fallback -- see the
+ // @SuppressLint and the http(s)-only guard just above.
+ this.settings.javaScriptEnabled = true
+ this.settings.domStorageEnabled = true
loadUrl(url)
}
},
diff --git a/app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt b/app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt
index c701a26..6240b77 100644
--- a/app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt
+++ b/app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt
@@ -594,14 +594,14 @@ private fun ReaderContent(
)
).apply {
val html = wrapReaderHtml(content, article?.title, article?.publishedAt, article?.imageUrl, fontSizePx)
- loadDataWithBaseURL(baseUrl, html, "text/html", "UTF-8", null)
+ loadReaderHtml(baseUrl, html, fontSizePx)
loadedKey.value = content to fontSizePx
}
},
update = { webView ->
if (loadedKey.value != content to fontSizePx) {
val html = wrapReaderHtml(content, article?.title, article?.publishedAt, article?.imageUrl, fontSizePx)
- webView.loadDataWithBaseURL(baseUrl, html, "text/html", "UTF-8", null)
+ webView.loadReaderHtml(baseUrl, html, fontSizePx)
loadedKey.value = content to fontSizePx
}
},
diff --git a/app/src/main/java/org/terst/feedreader/ui/ReaderWebView.kt b/app/src/main/java/org/terst/feedreader/ui/ReaderWebView.kt
index 9a11dd3..6d96e79 100644
--- a/app/src/main/java/org/terst/feedreader/ui/ReaderWebView.kt
+++ b/app/src/main/java/org/terst/feedreader/ui/ReaderWebView.kt
@@ -24,7 +24,9 @@ import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import org.terst.feedreader.data.DEFAULT_READER_FONT_SIZE_PX
+import org.terst.feedreader.data.UNZOOMED_TEXT_ZOOM_PERCENT
import org.terst.feedreader.data.pinchZoomFontSizePx
+import org.terst.feedreader.data.readerTextZoomPercent
// In-memory only (process lifetime, not persisted to disk) -- scoped to "don't lose my
// place if Next/Previous takes me away and back," not to surviving an app restart. A
@@ -99,16 +101,25 @@ private fun extractYouTubeVideoId(uri: Uri): String? {
* exactly as before. ScaleGestureDetector only calls back once a second pointer is down,
* so a single-finger drag never triggers any of this.
*
- * currentFontSizePx is read fresh (not captured once) when a gesture ends, since the
- * WebView this attaches to is created once but can outlive a Settings-screen-driven font
- * size change made while this same article is still open.
+ * currentFontSizePx is read fresh (not captured once) at the START of each gesture, since
+ * the WebView this attaches to is created once but can outlive a Settings-screen-driven
+ * font size change made while this same article is still open. Everything during the
+ * gesture is measured against that one starting size, so a pinch is judged against where
+ * it began rather than against a size it's already changed.
*
- * During the gesture the WebView is visually scaled live (View.scaleX/scaleY) for
- * immediate feedback -- cheap, and independent of the WebView's own content layout.
- * At gesture end the scale resets to 1x and onFontSizeChanged reports the new,
- * clamped, rounded font size for the caller to persist; ReaderScreen's existing
- * content-vs-fontSizePx reload logic (see ReaderScreen.ReaderContent) then re-renders
- * the actual HTML at that size.
+ * The text reflows live, as the fingers move: each step sets WebSettings.textZoom, which
+ * re-lays-out the text at the new size right away -- no reload, no JavaScript, and
+ * images/padding stay exactly where they are. An earlier version instead scaled the whole
+ * WebView (View.scaleX/scaleY) during the gesture and snapped back to 1x on release, which
+ * zoomed the canvas like a photo (blurring text, magnifying the page's own margins) and
+ * then visibly jumped when the real font size took over. Nothing is scaled now, so there's
+ * nothing to snap back from.
+ *
+ * At gesture end onFontSizeChanged reports the new, clamped, rounded font size for the
+ * caller to persist; ReaderScreen's content-vs-fontSizePx reload logic then re-renders the
+ * HTML at that size via loadReaderHtml, which is also what returns textZoom to 100% -- the
+ * live zoom stays applied until the reload it hands over to is on screen, so the two never
+ * both apply and there's no gap where the text flicks back to its old size.
*/
data class ReaderWebViewCallbacks(
val onOpenLink: (String) -> Unit,
@@ -121,8 +132,30 @@ data class ReaderWebViewCallbacks(
val onFontSizeChanged: (Int) -> Unit = {}
)
-fun newReaderWebView(context: Context, callbacks: ReaderWebViewCallbacks): WebView =
- WebView(context).apply {
+/**
+ * The reader's WebView, which knows what font size the HTML it's showing was written at.
+ *
+ * That's the whole reason it's a class rather than a plain WebView: a live pinch lifts
+ * WebSettings.textZoom off 100% and leaves it there until the reload at the new font size
+ * arrives (see the gesture notes above). Anything that loads HTML into this WebView must put
+ * textZoom back, or the lifted zoom multiplies the new, already-bigger CSS size -- so loading
+ * goes through [loadReaderHtml], which does both together and can't be forgotten the way a
+ * separate "and also reset the zoom" line at each call site could.
+ */
+class ReaderWebView(context: Context) : WebView(context) {
+ /** The `font-size` the currently-loaded HTML was written with (see wrapReaderHtml). */
+ var renderedFontSizePx: Int = DEFAULT_READER_FONT_SIZE_PX
+ private set
+
+ fun loadReaderHtml(baseUrl: String?, html: String, fontSizePx: Int) {
+ renderedFontSizePx = fontSizePx
+ settings.textZoom = UNZOOMED_TEXT_ZOOM_PERCENT
+ loadDataWithBaseURL(baseUrl, html, "text/html", "UTF-8", null)
+ }
+}
+
+fun newReaderWebView(context: Context, callbacks: ReaderWebViewCallbacks): ReaderWebView =
+ ReaderWebView(context).apply {
settings.javaScriptEnabled = false
overScrollMode = View.OVER_SCROLL_NEVER
isVerticalScrollBarEnabled = false
@@ -135,27 +168,29 @@ fun newReaderWebView(context: Context, callbacks: ReaderWebViewCallbacks): WebVi
setOnScrollChangeListener { _, _, scrollY, _, _ -> callbacks.onScrollChanged(scrollY) }
var pinchScale = 1f
+ var pinchBaseFontSizePx = DEFAULT_READER_FONT_SIZE_PX
val webView = this
val scaleDetector = ScaleGestureDetector(
context,
object : ScaleGestureDetector.SimpleOnScaleGestureListener() {
override fun onScaleBegin(detector: ScaleGestureDetector): Boolean {
pinchScale = 1f
+ pinchBaseFontSizePx = callbacks.currentFontSizePx()
return true
}
override fun onScale(detector: ScaleGestureDetector): Boolean {
pinchScale *= detector.scaleFactor
- webView.scaleX = pinchScale
- webView.scaleY = pinchScale
+ // Steps in whole pixels of font size, not in raw gesture scale, so a slow
+ // pinch relayouts a handful of times over its whole range rather than on
+ // every touch-move frame -- and lands on exactly the size it will persist.
+ val targetPx = pinchZoomFontSizePx(pinchBaseFontSizePx, pinchScale)
+ webView.settings.textZoom = readerTextZoomPercent(webView.renderedFontSizePx, targetPx)
return true
}
override fun onScaleEnd(detector: ScaleGestureDetector) {
- val newSizePx = pinchZoomFontSizePx(callbacks.currentFontSizePx(), pinchScale)
- webView.scaleX = 1f
- webView.scaleY = 1f
- callbacks.onFontSizeChanged(newSizePx)
+ callbacks.onFontSizeChanged(pinchZoomFontSizePx(pinchBaseFontSizePx, pinchScale))
}
}
)