summaryrefslogtreecommitdiff
path: root/app/src/main/java/org/terst/feedreader/ui
diff options
context:
space:
mode:
Diffstat (limited to 'app/src/main/java/org/terst/feedreader/ui')
-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
5 files changed, 251 insertions, 149 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)) {
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))
}
}
)