Skip to content

Commit 5e807f6

Browse files
qngamickael-menu
andauthored
Extract footnote popups from the Epub navigator (readium#448)
Co-authored-by: Mickaël Menu <mickael.menu@gmail.com>
1 parent b3b4033 commit 5e807f6

File tree

14 files changed

+409
-226
lines changed

14 files changed

+409
-226
lines changed

CHANGELOG.md

+7-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,13 @@ All notable changes to this project will be documented in this file. Take a look
44

55
**Warning:** Features marked as *experimental* may change or be removed in a future release without notice. Use with caution.
66

7-
<!-- ## [Unreleased] -->
7+
## [Unreleased]
8+
9+
### Added
10+
11+
* The new `HyperlinkNavigator.shouldFollowInternalLink(Link, LinkContext?)` allows you to handle footnotes according to your preference.
12+
* By default, the navigator now moves to the footnote content instead of displaying a pop-up as it did in version 2.x.
13+
814

915
## [3.0.0-alpha.1]
1016

readium/navigator/src/main/java/org/readium/r2/navigator/HyperlinkNavigator.kt

+14-2
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,18 @@ import org.readium.r2.shared.util.AbsoluteUrl
1616
@ExperimentalReadiumApi
1717
public interface HyperlinkNavigator : Navigator {
1818

19+
@ExperimentalReadiumApi
20+
public sealed interface LinkContext
21+
22+
/**
23+
* @param noteContent Content of the footnote. Look at the [Link.mediaType] for the format
24+
* of the footnote (e.g. HTML).
25+
*/
26+
@ExperimentalReadiumApi
27+
public data class FootnoteContext(
28+
public val noteContent: String
29+
) : LinkContext
30+
1931
@ExperimentalReadiumApi
2032
public interface Listener : Navigator.Listener {
2133

@@ -26,10 +38,10 @@ public interface HyperlinkNavigator : Navigator {
2638
* or other operations.
2739
*
2840
* By returning false the navigator wont try to open the link itself and it is up
29-
* to the calling app to decide how to display the link.
41+
* to the calling app to decide how to display the resource.
3042
*/
3143
@ExperimentalReadiumApi
32-
public fun shouldFollowInternalLink(link: Link): Boolean { return true }
44+
public fun shouldFollowInternalLink(link: Link, context: LinkContext?): Boolean { return true }
3345

3446
/**
3547
* Called when a link to an external URL was activated in the navigator.

readium/navigator/src/main/java/org/readium/r2/navigator/R2BasicWebView.kt

+32-49
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,12 @@ import android.graphics.PointF
1212
import android.graphics.Rect
1313
import android.graphics.RectF
1414
import android.os.Build
15-
import android.text.Html
1615
import android.util.AttributeSet
1716
import android.view.*
1817
import android.webkit.URLUtil
1918
import android.webkit.WebResourceRequest
2019
import android.webkit.WebResourceResponse
2120
import android.webkit.WebView
22-
import android.widget.ImageButton
23-
import android.widget.ListPopupWindow
24-
import android.widget.PopupWindow
25-
import android.widget.TextView
2621
import androidx.annotation.RequiresApi
2722
import kotlin.coroutines.resume
2823
import kotlin.coroutines.suspendCoroutine
@@ -46,6 +41,7 @@ import org.readium.r2.shared.extensions.tryOrLog
4641
import org.readium.r2.shared.extensions.tryOrNull
4742
import org.readium.r2.shared.publication.Link
4843
import org.readium.r2.shared.publication.Locator
44+
import org.readium.r2.shared.util.AbsoluteUrl
4945
import org.readium.r2.shared.util.Url
5046
import org.readium.r2.shared.util.data.decodeString
5147
import org.readium.r2.shared.util.flatMap
@@ -87,6 +83,9 @@ internal open class R2BasicWebView(context: Context, attrs: AttributeSet) : WebV
8783
@InternalReadiumApi
8884
fun shouldInterceptRequest(webView: WebView, request: WebResourceRequest): WebResourceResponse? = null
8985

86+
@InternalReadiumApi
87+
fun shouldFollowFootnoteLink(url: AbsoluteUrl, context: HyperlinkNavigator.FootnoteContext): Boolean
88+
9089
@InternalReadiumApi
9190
fun resourceAtUrl(url: Url): Resource? = null
9291

@@ -115,7 +114,7 @@ internal open class R2BasicWebView(context: Context, attrs: AttributeSet) : WebV
115114
var listener: Listener? = null
116115
internal var preferences: SharedPreferences? = null
117116

118-
var resourceUrl: Url? = null
117+
var resourceUrl: AbsoluteUrl? = null
119118

120119
internal val scrollModeFlow = MutableStateFlow(false)
121120

@@ -128,6 +127,12 @@ internal open class R2BasicWebView(context: Context, attrs: AttributeSet) : WebV
128127

129128
private val uiScope = CoroutineScope(Dispatchers.Main)
130129

130+
/*
131+
* Url already handled by listener.shouldFollowFootnoteLink,
132+
* Tries to ignore the matching shouldOverrideUrlLoading call.
133+
*/
134+
private var urlNotToOverrideLoading: AbsoluteUrl? = null
135+
131136
init {
132137
setWebContentsDebuggingEnabled(BuildConfig.DEBUG)
133138
}
@@ -277,8 +282,6 @@ internal open class R2BasicWebView(context: Context, attrs: AttributeSet) : WebV
277282
return false
278283
}
279284

280-
// FIXME: Let the app handle footnotes.
281-
282285
// We ignore taps on interactive element, unless it's an element we handle ourselves such as
283286
// pop-up footnotes.
284287
if (event.interactiveElement != null) {
@@ -344,11 +347,13 @@ internal open class R2BasicWebView(context: Context, attrs: AttributeSet) : WebV
344347

345348
val id = href.fragment ?: return false
346349

347-
val absoluteUrl = resourceUrl.resolve(href).removeFragment()
350+
val absoluteUrl = resourceUrl.resolve(href)
351+
352+
val absoluteUrlWithoutFragment = absoluteUrl.removeFragment()
348353

349354
val aside = runBlocking {
350355
tryOrLog {
351-
listener?.resourceAtUrl(absoluteUrl)
356+
listener?.resourceAtUrl(absoluteUrlWithoutFragment)
352357
?.use { res ->
353358
res.read()
354359
.flatMap { it.decodeString() }
@@ -358,50 +363,22 @@ internal open class R2BasicWebView(context: Context, attrs: AttributeSet) : WebV
358363
?.select("#$id")
359364
?.first()?.html()
360365
}
361-
} ?: return false
366+
}?.takeIf { it.isNotBlank() }
367+
?: return false
362368

363369
val safe = Jsoup.clean(aside, Safelist.relaxed())
364-
365-
// Initialize a new instance of LayoutInflater service
366-
val inflater = context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater
367-
368-
// Inflate the custom layout/view
369-
val customView = inflater.inflate(R.layout.readium_navigator_popup_footnote, null)
370-
371-
// Initialize a new instance of popup window
372-
val mPopupWindow = PopupWindow(
373-
customView,
374-
ListPopupWindow.WRAP_CONTENT,
375-
ListPopupWindow.WRAP_CONTENT
370+
val context = HyperlinkNavigator.FootnoteContext(
371+
noteContent = safe
376372
)
377-
mPopupWindow.isOutsideTouchable = true
378-
mPopupWindow.isFocusable = true
379373

380-
// Set an elevation value for popup window
381-
// Call requires API level 21
382-
mPopupWindow.elevation = 5.0f
374+
val shouldFollowLink = listener?.shouldFollowFootnoteLink(absoluteUrl, context) ?: true
383375

384-
val textView = customView.findViewById(R.id.footnote) as TextView
385-
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
386-
textView.text = Html.fromHtml(safe, Html.FROM_HTML_MODE_COMPACT)
387-
} else {
388-
@Suppress("DEPRECATION")
389-
textView.text = Html.fromHtml(safe)
376+
if (shouldFollowLink) {
377+
urlNotToOverrideLoading = absoluteUrl
390378
}
391379

392-
// Get a reference for the custom view close button
393-
val closeButton = customView.findViewById(R.id.ib_close) as ImageButton
394-
395-
// Set a click listener for the popup window close button
396-
closeButton.setOnClickListener {
397-
// Dismiss the popup window
398-
mPopupWindow.dismiss()
399-
}
400-
401-
// Finally, show the popup window at the center location of root relative layout
402-
mPopupWindow.showAtLocation(this, Gravity.CENTER, 0, 0)
403-
404-
return true
380+
// Consume event if the link should not be followed.
381+
return !shouldFollowLink
405382
}
406383

407384
@android.webkit.JavascriptInterface
@@ -596,9 +573,15 @@ internal open class R2BasicWebView(context: Context, attrs: AttributeSet) : WebV
596573
}
597574

598575
internal fun shouldOverrideUrlLoading(request: WebResourceRequest): Boolean {
599-
if (resourceUrl == request.url.toUrl()) return false
576+
val requestUrl = request.url.toUrl() ?: return false
600577

601-
return listener?.shouldOverrideUrlLoading(this, request) ?: false
578+
// FIXME: I doubt this can work well. hasGesture considers itself unreliable.
579+
return if (urlNotToOverrideLoading == requestUrl && request.hasGesture()) {
580+
urlNotToOverrideLoading = null
581+
false
582+
} else {
583+
listener?.shouldOverrideUrlLoading(this, request) ?: false
584+
}
602585
}
603586

604587
internal fun shouldInterceptRequest(webView: WebView, request: WebResourceRequest): WebResourceResponse? {

readium/navigator/src/main/java/org/readium/r2/navigator/epub/EpubNavigatorFragment.kt

+8-1
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ import org.readium.r2.shared.publication.ReadingProgression as PublicationReadin
9090
import org.readium.r2.shared.publication.epub.EpubLayout
9191
import org.readium.r2.shared.publication.presentation.presentation
9292
import org.readium.r2.shared.publication.services.positionsByReadingOrder
93+
import org.readium.r2.shared.util.AbsoluteUrl
9394
import org.readium.r2.shared.util.Url
9495
import org.readium.r2.shared.util.mediatype.MediaType
9596
import org.readium.r2.shared.util.resource.Resource
@@ -506,7 +507,7 @@ public class EpubNavigatorFragment internal constructor(
506507
}
507508

508509
viewLifecycleOwner.lifecycleScope.launch {
509-
withStarted {
510+
viewLifecycleOwner.withStarted {
510511
// Restore the last locator before a configuration change (e.g. screen rotation), or the
511512
// initial locator when given.
512513
val locator = savedInstanceState?.let {
@@ -831,6 +832,12 @@ public class EpubNavigatorFragment internal constructor(
831832
return true
832833
}
833834

835+
override fun shouldFollowFootnoteLink(
836+
url: AbsoluteUrl,
837+
context: HyperlinkNavigator.FootnoteContext
838+
): Boolean =
839+
viewModel.shouldFollowFootnoteLink(url, context)
840+
834841
override fun shouldInterceptRequest(webView: WebView, request: WebResourceRequest): WebResourceResponse? =
835842
viewModel.shouldInterceptRequest(request)
836843

readium/navigator/src/main/java/org/readium/r2/navigator/epub/EpubNavigatorViewModel.kt

+6-1
Original file line numberDiff line numberDiff line change
@@ -177,14 +177,19 @@ internal class EpubNavigatorViewModel(
177177
fun navigateToUrl(url: AbsoluteUrl) = viewModelScope.launch {
178178
val link = internalLinkFromUrl(url)
179179
if (link != null) {
180-
if (listener == null || listener.shouldFollowInternalLink(link)) {
180+
if (listener == null || listener.shouldFollowInternalLink(link, null)) {
181181
_events.send(Event.OpenInternalLink(link))
182182
}
183183
} else {
184184
listener?.onExternalLinkActivated(url)
185185
}
186186
}
187187

188+
fun shouldFollowFootnoteLink(url: AbsoluteUrl, context: HyperlinkNavigator.FootnoteContext): Boolean {
189+
val link = internalLinkFromUrl(url) ?: return true
190+
return listener?.shouldFollowInternalLink(link, context) ?: true
191+
}
192+
188193
/**
189194
* Gets the publication [Link] targeted by the given [url].
190195
*/

readium/navigator/src/main/java/org/readium/r2/navigator/pager/R2EpubPageFragment.kt

+4-4
Original file line numberDiff line numberDiff line change
@@ -43,13 +43,13 @@ import org.readium.r2.shared.ExperimentalReadiumApi
4343
import org.readium.r2.shared.InternalReadiumApi
4444
import org.readium.r2.shared.publication.Link
4545
import org.readium.r2.shared.publication.Locator
46-
import org.readium.r2.shared.util.Url
46+
import org.readium.r2.shared.util.AbsoluteUrl
4747

4848
@OptIn(ExperimentalReadiumApi::class)
4949
internal class R2EpubPageFragment : Fragment() {
5050

51-
private val resourceUrl: Url?
52-
get() = BundleCompat.getParcelable(requireArguments(), "url", Url::class.java)
51+
private val resourceUrl: AbsoluteUrl?
52+
get() = BundleCompat.getParcelable(requireArguments(), "url", AbsoluteUrl::class.java)
5353

5454
internal val link: Link?
5555
get() = BundleCompat.getParcelable(requireArguments(), "link", Link::class.java)
@@ -436,7 +436,7 @@ internal class R2EpubPageFragment : Fragment() {
436436
private const val textZoomBundleKey = "org.readium.textZoom"
437437

438438
fun newInstance(
439-
url: Url,
439+
url: AbsoluteUrl,
440440
link: Link? = null,
441441
initialLocator: Locator? = null,
442442
positionCount: Int = 0

readium/navigator/src/main/java/org/readium/r2/navigator/pager/R2PagerAdapter.kt

+2-1
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import androidx.fragment.app.Fragment
1818
import androidx.fragment.app.FragmentManager
1919
import org.readium.r2.shared.publication.Link
2020
import org.readium.r2.shared.publication.Locator
21+
import org.readium.r2.shared.util.AbsoluteUrl
2122
import org.readium.r2.shared.util.Url
2223

2324
internal class R2PagerAdapter internal constructor(
@@ -32,7 +33,7 @@ internal class R2PagerAdapter internal constructor(
3233
internal var listener: Listener? = null
3334

3435
internal sealed class PageResource {
35-
data class EpubReflowable(val link: Link, val url: Url, val positionCount: Int) : PageResource()
36+
data class EpubReflowable(val link: Link, val url: AbsoluteUrl, val positionCount: Int) : PageResource()
3637
data class EpubFxl(
3738
val leftLink: Link? = null,
3839
val leftUrl: Url? = null,

test-app/src/main/java/org/readium/r2/testapp/reader/BaseReaderFragment.kt

+6-5
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import org.readium.r2.shared.ExperimentalReadiumApi
2323
import org.readium.r2.shared.publication.Locator
2424
import org.readium.r2.shared.publication.Publication
2525
import org.readium.r2.testapp.R
26-
import org.readium.r2.testapp.reader.preferences.UserPreferencesBottomSheetDialogFragment
26+
import org.readium.r2.testapp.reader.preferences.MainPreferencesBottomSheetDialogFragment
2727
import org.readium.r2.testapp.utils.UserError
2828

2929
/*
@@ -48,8 +48,10 @@ abstract class BaseReaderFragment : Fragment() {
4848
}
4949

5050
when (event) {
51-
is ReaderViewModel.FeedbackEvent.BookmarkFailed -> toast(R.string.bookmark_exists)
52-
is ReaderViewModel.FeedbackEvent.BookmarkSuccessfullyAdded -> toast(
51+
is ReaderViewModel.FragmentFeedback.BookmarkFailed -> toast(
52+
R.string.bookmark_exists
53+
)
54+
is ReaderViewModel.FragmentFeedback.BookmarkSuccessfullyAdded -> toast(
5355
R.string.bookmark_added
5456
)
5557
}
@@ -86,8 +88,7 @@ abstract class BaseReaderFragment : Fragment() {
8688
return true
8789
}
8890
R.id.settings -> {
89-
val settingsModel = checkNotNull(model.settings)
90-
UserPreferencesBottomSheetDialogFragment(settingsModel, "User Settings")
91+
MainPreferencesBottomSheetDialogFragment()
9192
.show(childFragmentManager, "Settings")
9293
return true
9394
}

0 commit comments

Comments
 (0)