diff --git a/.vscode/settings.json b/.vscode/settings.json index d45b4a7a..821cd436 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -44,6 +44,7 @@ "cSpell.words": [ "acsm", "androidx", + "animejs", "ascii", "Ascii", "charsets", diff --git a/bin/install b/bin/install index b035ff2d..0fc9c430 100755 --- a/bin/install +++ b/bin/install @@ -12,4 +12,5 @@ if [ "$(uname)" == "Darwin" ]; then fi $PROJECT_ROOT/flutter_readium/bin/build_helper_scripts.sh +$PROJECT_ROOT/bin/build_js $PROJECT_ROOT/bin/update_web_example diff --git a/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/ReadiumExtensions.kt b/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/ReadiumExtensions.kt index fe16944f..fc6ceadb 100644 --- a/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/ReadiumExtensions.kt +++ b/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/ReadiumExtensions.kt @@ -18,11 +18,17 @@ import org.readium.r2.shared.publication.Manifest import org.readium.r2.shared.publication.Publication import org.readium.r2.shared.publication.flatten import org.readium.r2.shared.publication.html.cssSelector +import org.readium.r2.shared.publication.services.content.Content +import org.readium.r2.shared.publication.services.content.ContentService import org.readium.r2.shared.util.Try +import org.readium.r2.shared.util.Url import org.readium.r2.shared.util.mediatype.MediaType import org.readium.r2.shared.util.resource.Resource import org.readium.r2.shared.util.resource.TransformingResource import org.readium.r2.shared.util.resource.filename +import java.util.Locale +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds import org.readium.r2.navigator.preferences.Color as ReadiumColor private const val TAG = "ReadiumExtensions" @@ -150,10 +156,10 @@ suspend fun Publication.getMediaOverlays(): List? { if (!hasMediaOverlays()) return null // Flatten TOC for title lookup - val toc = tableOfContents.flatten().map { Pair(it.href.toString(), it.title) } + val toc = tableOfContents.flatten().map { Pair(it.href.toString(), it) } // Remember last matched TOC item for titles - var lastTocMatch: Pair? = null + var lastTocMatch: Pair? = null return this.readingOrder.mapNotNull { r -> r.alternates.find { a -> @@ -163,7 +169,7 @@ suspend fun Publication.getMediaOverlays(): List? { val jsonString = this.get(link)?.read()?.getOrNull()?.let { String(it) } ?: return@mapIndexedNotNull null val jsonObject = JSONObject(jsonString) - FlutterMediaOverlay.fromJson(jsonObject, index + 1, link.title ?: "") + FlutterMediaOverlay.fromJson(jsonObject, index + 1, null, link.title ?: "") } .map { mo -> val items = mo.items.map { item -> @@ -174,9 +180,15 @@ suspend fun Publication.getMediaOverlays(): List? { if (match?.second != null) { lastTocMatch = match - item.copy(title = match.second ?: "") + item.copy( + title = match.second.title ?: "", + tocHref = match.second.href.resolve() + ) } else if (lastTocMatch?.second != null && lastTocMatch.first.substringBefore("#") == item.textFile) { - item.copy(title = lastTocMatch.second ?: "") + item.copy( + title = lastTocMatch.second.title ?: "", + tocHref = lastTocMatch.second.href.resolve() + ) } else { item } @@ -247,7 +259,127 @@ fun Locator.getTextId(): String? { fun Locator.copyWithTimeFragment(time: Double): Locator { return copy( locations = locations.copy( - fragments = listOf("${time}") + fragments = listOf("t=${time}") ) ) } + +/** + * Helper for getting all cssSelectors for a HTML document. + */ +suspend fun Publication.findAllCssSelectors(href: Url): List? { + if (!conformsTo(Publication.Profile.EPUB)) { + Log.d(TAG, ":findAllCssSelectors - this only works for an EPUB Profile") + return null + } + + val contentService = findService(ContentService::class) ?: run { + Log.d(TAG, ":findAllCssSelectors - no content service found") + return null + } + + val cleanHref = href.cleanHref() + + val ids = arrayListOf() + for (element in contentService.content( + Locator( + href = cleanHref, + mediaType = MediaType.XHTML + ) + )) { + if (element !is Content.TextElement) { + continue + } + + if (element.locator.href.cleanHref().path != cleanHref.path) { + // We iterated to the next document, stopping + break + } + + // We are only interested in #id type of cssSelectors. + val cssSelector = + element.locator.locations.cssSelector?.takeIf { it.startsWith("#") } ?: continue + + ids.add(cssSelector) + } + + return ids +} + +/** + * Find the cssSelector for a locator. If it already have one return it, otherwise we need to look it up. + */ +suspend fun Publication.findCssSelectorForLocator(locator: Locator): String? { + // If our locator already has a cssSelector, use it. + locator.locations.cssSelector?.takeIf { it.startsWith("#") }?.let { return it } + + val contentService = findService(ContentService::class) ?: run { + Log.d(TAG, ":findCssSelectorForLocator - no content service found") + return null + } + + val cleanHref = locator.href.cleanHref() + + val locatorProgress = locator.locations.progression ?: 0.0 + var cssSelector: String? = null + for (element in contentService.content(locator)) { + if (element !is Content.TextElement) { + continue + } + + if (element.locator.href.cleanHref().path != cleanHref.path) { + // We iterated to the next document, stopping + break + } + + val eCssSelector = element.locator.locations.cssSelector?.takeIf { it.startsWith("#") } + if (eCssSelector == null || !eCssSelector.startsWith("#")) continue + + val progression = element.locator.locations.progression ?: 0.0 + if (progression > locatorProgress && cssSelector != null) { + break + } + + cssSelector = eCssSelector + } + + return cssSelector?.takeIf { it.isNotEmpty() && it.startsWith("#") } +} + +/** + * Remove query and fragment from the Url + */ +fun Url.cleanHref() = removeFragment().removeQuery() + +val Href.fragmentParameters: Map + get() = resolve() + .fragment + // Splits parameters + ?.split("&") + ?.filter { !it.startsWith("=") } + ?.map { it.split("=") } + // Only keep named parameters + ?.filter { it.size == 2 } + ?.associate { it[0].trim().lowercase(Locale.ROOT) to it[1].trim() } + ?: mapOf() + + +/** + * Media fragment, used for example in audiobooks. + * + * https://www.w3.org/TR/media-frags/ + */ +val Href.time: Duration? + get() = + fragmentParameters["t"]?.toIntOrNull()?.seconds + +/** + * Returns a list of `Link` after flattening the `children`. + */ +fun List.flattenChildren(): List { + fun Link.flattenChildren(): List { + return listOf(this) + children.flattenChildren() + } + + return flatMap { it.flattenChildren() } +} diff --git a/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/ReadiumReader.kt b/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/ReadiumReader.kt index 191292c4..5ce12bf6 100644 --- a/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/ReadiumReader.kt +++ b/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/ReadiumReader.kt @@ -40,6 +40,7 @@ import org.readium.navigator.media.tts.android.AndroidTtsPreferences import org.readium.navigator.media.tts.android.AndroidTtsSettings import org.readium.r2.navigator.Decoration import org.readium.r2.navigator.epub.EpubPreferences +import org.readium.r2.navigator.extensions.time import org.readium.r2.shared.ExperimentalReadiumApi import org.readium.r2.shared.InternalReadiumApi import org.readium.r2.shared.publication.Link @@ -176,9 +177,6 @@ object ReadiumReader : TimebasedNavigator.TimebasedListener, EpubNavigator.Visua private var epubNavigator: EpubNavigator? = null - val epubCurrentLocator: Locator? - get() = epubNavigator?.currentLocator?.value - private var _audioPreferences: FlutterAudioPreferences = FlutterAudioPreferences() /** Current audio preferences (defaults if audio hasn't been enabled yet). */ @@ -398,6 +396,13 @@ object ReadiumReader : TimebasedNavigator.TimebasedListener, EpubNavigator.Visua state[currentPublicationUrlKey] = value } + /*** + * For EPUB profile, maps document [Url] to a list of all the cssSelectors in the document. + * + * This is used to find the current toc item. + */ + private var currentPublicationCssSelectorMap: MutableMap>? = null + /** * Sets the headers used in the HTTP requests for fetching publication resources, including * resources in already created `Publication` objects. @@ -579,6 +584,7 @@ object ReadiumReader : TimebasedNavigator.TimebasedListener, EpubNavigator.Visua mainScope.async { _currentPublication?.close() _currentPublication = null + currentPublicationCssSelectorMap = null ttsNavigator?.dispose() ttsNavigator = null @@ -608,12 +614,13 @@ object ReadiumReader : TimebasedNavigator.TimebasedListener, EpubNavigator.Visua // TODO: Notify client } + @OptIn(InternalReadiumApi::class) override fun onTimebasedCurrentLocatorChanges( locator: Locator, currentReadingOrderLink: Link? ) { val duration = currentReadingOrderLink?.duration val timeOffset = - locator.locations.fragments.find { it.startsWith("t=") }?.substring(2)?.toDoubleOrNull() + locator.locations.time?.inWholeSeconds?.toDouble() ?: (duration?.let { duration -> locator.locations.progression?.let { prog -> duration * prog } }) @@ -631,6 +638,63 @@ object ReadiumReader : TimebasedNavigator.TimebasedListener, EpubNavigator.Visua currentReaderWidget?.go(locator, true) } + /** + * Find the current table of content item from a locator. + */ + suspend fun epubFindCurrentToc(locator: Locator): Locator { + val publication = currentPublication ?: run { + Log.e(TAG, ":epubFindCurrentToc - no currentPublication") + return locator + } + + if (!publication.conformsTo(Publication.Profile.EPUB)) { + Log.d(TAG, ":epubFindCurrentToc - not an EPUB profile") + return locator + } + + val cssSelector = publication.findCssSelectorForLocator(locator) ?: run { + Log.e(TAG, ":epubFindCurrentToc - missing cssSelector in locator") + return locator + } + + val resultLocator = + locator.copyWithLocations( + otherLocations = locator.locations.otherLocations + + ("cssSelector" to cssSelector) + ) + + val cleanHref = resultLocator.href.cleanHref() + val tocLinks = publication.tableOfContents.flattenChildren().filter { + it.href.resolve().cleanHref().path == cleanHref.path + } + + val documentCssSelectors = epubGetAllDocumentCssSelectors(resultLocator.href) + val idx = documentCssSelectors.indexOf(cssSelector).takeIf { it > -1 } ?: run { + // cssSelector wasn't found in the list of document cssSelectors, best effort is to assume first + Log.d( + TAG, + ":epubFindCurrentToc cssSelector:${cssSelector} not found in contentIds, assume idx = 0" + ) + 0 + } + + val toc = + tocLinks.associateBy { documentCssSelectors.indexOf("#${it.href.resolve().fragment}") } + + val tocItem = toc.entries.lastOrNull { it.key <= idx }?.value + ?: toc.entries.firstOrNull()?.value ?: run { + Log.d(TAG, ":epubFindCurrentToc - no tocItem found") + return resultLocator + } + + return resultLocator.copy( + title = tocItem.title + ).copyWithLocations( + otherLocations = resultLocator.locations.otherLocations + + ("toc" to tocItem.href.resolve().toString()) + ) + } + @OptIn(InternalReadiumApi::class) suspend fun epubEnable( initialLocator: Locator?, @@ -716,7 +780,8 @@ object ReadiumReader : TimebasedNavigator.TimebasedListener, EpubNavigator.Visua ) }, { language, availableVoices -> null }, - AndroidTtsPreferences())?.voices ?: setOf() + AndroidTtsPreferences() + )?.voices ?: setOf() } fun ttsGetPreferences(): FlutterTtsPreferences? { @@ -929,10 +994,26 @@ object ReadiumReader : TimebasedNavigator.TimebasedListener, EpubNavigator.Visua } /** - * Get locator fragments from EPUB navigator. + * Get first visible locator from the EPUB navigator. + */ + suspend fun firstVisibleElementLocator(): Locator? { + return epubNavigator?.firstVisibleElementLocator() + } + + /** + * Get all cssSelectors for an EPUB file. + * Note: These only includes text elements, so body, page breaks etc are not included. */ - suspend fun epubGetLocatorFragments(locator: Locator): Locator? { - return epubNavigator?.getLocatorFragments(locator) + suspend fun epubGetAllDocumentCssSelectors(href: Url): List { + val cssSelectorMap = currentPublicationCssSelectorMap ?: mutableMapOf() + currentPublicationCssSelectorMap = cssSelectorMap + + val cleanHref = href.cleanHref() + return cssSelectorMap.getOrPut(cleanHref) { + currentPublication?.findAllCssSelectors( + cleanHref + ) ?: listOf() + } } /** diff --git a/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/ReadiumReaderWidget.kt b/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/ReadiumReaderWidget.kt index 4f81f1ce..e8bfa38a 100644 --- a/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/ReadiumReaderWidget.kt +++ b/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/ReadiumReaderWidget.kt @@ -13,6 +13,7 @@ import androidx.fragment.app.FragmentActivity import androidx.fragment.app.commitNow import dk.nota.flutter_readium.events.ReadiumReaderStatus import dk.nota.flutter_readium.fragments.EpubReaderFragment +import dk.nota.flutter_readium.models.PageInformation import dk.nota.flutter_readium.navigators.EpubNavigator import io.flutter.plugin.common.BinaryMessenger import io.flutter.plugin.common.MethodCall @@ -41,8 +42,8 @@ class ReadiumReaderWidget( creationParams: Map, messenger: BinaryMessenger, attrs: AttributeSet? = null -) : PlatformView, MethodChannel.MethodCallHandler, - EpubReaderFragment.Listener, EpubNavigator.VisualListener { +) : PlatformView, MethodChannel.MethodCallHandler, EpubReaderFragment.Listener, + EpubNavigator.VisualListener { private val channel: ReadiumReaderChannel @@ -94,8 +95,8 @@ class ReadiumReaderWidget( init { Log.d(TAG, "::init") - @Suppress("UNCHECKED_CAST") - val initPrefsMap = creationParams["preferences"] as Map? + @Suppress("UNCHECKED_CAST") val initPrefsMap = + creationParams["preferences"] as Map? val publication = ReadiumReader.currentPublication val locatorString = creationParams["initialLocator"] as String? val allowScreenReaderNavigation = creationParams["allowScreenReaderNavigation"] as Boolean? @@ -103,8 +104,7 @@ class ReadiumReaderWidget( if (locatorString == null) null else Locator.fromJSON(jsonDecode(locatorString) as JSONObject) val initialPreferences = if (initPrefsMap == null) EpubPreferences() else epubPreferencesFromMap( - initPrefsMap, - null + initPrefsMap, null ) Log.d(TAG, "publication = $publication") @@ -126,8 +126,7 @@ class ReadiumReaderWidget( // This can be toggled back on via the 'allowScreenReaderNavigation' creation param. // See issue: https://notalib.atlassian.net/browse/NOTA-9828 if (allowScreenReaderNavigation != true) { - layout.importantForAccessibility = - View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS + layout.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS } // Remove existing fragment if any (this is to avoid crashing on restore). @@ -178,7 +177,8 @@ class ReadiumReaderWidget( ReadiumReader.emitReaderStatusUpdate(ReadiumReaderStatus.Ready) } - emitOnPageChanged(locator) + + emitOnPageChanged(pageIndex, totalPages, locator) } } @@ -210,18 +210,39 @@ class ReadiumReaderWidget( updatePreferences(newPreferences) } - private suspend fun emitOnPageChanged(locator: Locator) { + private suspend fun emitOnPageChanged(pageIndex: Int, totalPages: Int, locator: Locator) { try { - val locatorWithFragments = ReadiumReader.epubGetLocatorFragments(locator) - if (locatorWithFragments == null) { - Log.e(TAG, "emitOnPageChanged: window.epubPage.getVisibleRange failed!") - return + var emittingLocator = locator + + try { + evaluateJavascript("window.epubPage.getPageInformation()")?.let { + PageInformation.fromJson( + it, locator.href + ) + }?.let { pageInfo -> + emittingLocator = emittingLocator.copy( + locations = emittingLocator.locations.copy( + otherLocations = emittingLocator.locations.otherLocations + pageInfo.otherLocations, + ), + ); + } + } catch (e: Error) { + Log.d(TAG, ":pageInformation error: $e") } - channel.onPageChanged(locatorWithFragments) - ReadiumReader.emitTextLocatorUpdate(locatorWithFragments) + emittingLocator = emittingLocator.copy( + locations = emittingLocator.locations.copy( + otherLocations = emittingLocator.locations.otherLocations + ("currentPage" to pageIndex) + ("totalPages" to totalPages) + ) + ) + + emittingLocator = ReadiumReader.epubFindCurrentToc(emittingLocator) + + channel.onPageChanged(emittingLocator) + ReadiumReader.emitTextLocatorUpdate(emittingLocator) + Log.d(TAG, "emitOnPageChanged: emitted $emittingLocator") } catch (e: Exception) { - Log.e(TAG, "emitOnPageChanged: window.epubPage.getVisibleRange failed! $e") + Log.e(TAG, "emitOnPageChanged: failed! $e") } } @@ -229,15 +250,6 @@ class ReadiumReaderWidget( channel.onExternalLinkActivated(url) } - private suspend fun setLocation( - locator: Locator, - isAudioBookWithText: Boolean - ) { - val json = locator.toJSON().toString() - Log.d(TAG, "::scrollToLocations: Go to locations $json") - evaluateJavascript("window.epubPage.setLocation($json, $isAudioBookWithText);") - } - override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { // TODO: To be safe we're doing everything on the Main thread right now. // Could probably optimize by using .IO and then change to Main @@ -246,9 +258,17 @@ class ReadiumReaderWidget( Log.d(TAG, "::onMethodCall ${call.method}") when (call.method) { "setPreferences" -> { - @Suppress("UNCHECKED_CAST") - val prefsMap = call.arguments as Map try { + @Suppress("UNCHECKED_CAST") val prefsMap = + call.arguments as? Map ?: run { + result.error( + "FlutterReadium", + "Failed to set preferences", + "Invalid argument" + ) + return@launch + } + setPreferencesFromMap(prefsMap) result.success(null) } catch (ex: Exception) { @@ -264,13 +284,11 @@ class ReadiumReaderWidget( if (locatorJson.optString("type") == "") { locatorJson.put("type", " ") Log.e( - TAG, - "Got locator with empty type! This shouldn't happen. $locatorJson" + TAG, "Got locator with empty type! This shouldn't happen. $locatorJson" ) } val locator = Locator.fromJSON(locatorJson)!! ReadiumReader.epubGoToLocator(locator, animated) - setLocation(locator, isAudioBookWithText) result.success(null) } @@ -286,53 +304,12 @@ class ReadiumReaderWidget( result.success(null) } - "setLocation" -> { - val args = call.arguments as List<*> - val locatorJson = JSONObject(args[0] as String) - val isAudioBookWithText = args[1] as Boolean - val locator = Locator.fromJSON(locatorJson)!! - setLocation(locator, isAudioBookWithText) - result.success(null) - } - - "isLocatorVisible" -> { - val args = call.arguments as String - val locatorJson = JSONObject(args) - val locator = Locator.fromJSON(locatorJson)!! - var visible = locator.href == ReadiumReader.epubCurrentLocator?.href - if (visible) { - val jsonRes = - evaluateJavascript("window.epubPage.isLocatorVisible($args);") - ?: "false" - try { - visible = jsonDecode(jsonRes) as Boolean - } catch (e: Error) { - Log.e(TAG, "::isLocatorVisible - invalid response:$jsonRes - e:$e") - visible = false - } - } - result.success(visible) - } - - "getLocatorFragments" -> { - val args = call.arguments as String? - Log.d(TAG, "::====== $args") - val locatorJson = JSONObject(args!!) - Log.d(TAG, "::====== $locatorJson") - - val locator = - ReadiumReader.epubGetLocatorFragments(Locator.fromJSON(locatorJson)!!) - Log.d(TAG, "::====== $locator") - - result.success(jsonEncode(locator?.toJSON())) - } - "applyDecorations" -> { val args = call.arguments as List<*> val groupId = args[0] as String - @Suppress("UNCHECKED_CAST") - val decorationListStr = args[1] as List> + @Suppress("UNCHECKED_CAST") val decorationListStr = + args[1] as List> val decorations = decorationListStr.mapNotNull { decorationFromMap(it) } ReadiumReader.applyDecorations(decorations, groupId) @@ -344,10 +321,6 @@ class ReadiumReaderWidget( result.success(null) } - "getCurrentLocator" -> { - result.success(ReadiumReader.epubCurrentLocator?.let { jsonEncode(it.toJSON()) }) - } - else -> { Log.e(TAG, "Unhandled call ${call.method}") result.notImplemented() diff --git a/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/Utils.kt b/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/Utils.kt index 59cc76a4..8cf1ea4e 100644 --- a/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/Utils.kt +++ b/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/Utils.kt @@ -8,6 +8,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.withContext import org.json.JSONArray +import org.json.JSONException import org.json.JSONObject import org.readium.r2.shared.publication.Locator import org.readium.r2.shared.publication.html.cssSelector @@ -90,3 +91,12 @@ fun canScroll(locations: Locator.Locations) = fun MutableStateFlow.update(new: T) { if (this.value != new) this.value = new } + +@Throws(JSONException::class) +fun JSONArray.toList(): List { + val list = mutableListOf() + for (i in 0 until this.length()) { + list.add(this[i]) + } + return list +} diff --git a/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/models/FlutterMediaOverlay.kt b/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/models/FlutterMediaOverlay.kt index 83aa0e93..b08af5b3 100644 --- a/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/models/FlutterMediaOverlay.kt +++ b/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/models/FlutterMediaOverlay.kt @@ -111,14 +111,14 @@ data class FlutterMediaOverlay(val items: List) : Seria } companion object { - fun fromJson(json: JSONObject, position: Int, title: String): FlutterMediaOverlay? { + fun fromJson(json: JSONObject, position: Int, tocHref: Url?, title: String): FlutterMediaOverlay? { val topNarration = json.opt("narration") as? JSONArray ?: return null val items = mutableListOf() for (i in 0 until topNarration.length()) { val itemJson = topNarration.getJSONObject(i) - FlutterMediaOverlayItem.fromJson(itemJson, position, title)?.let { items.add(it) } + FlutterMediaOverlayItem.fromJson(itemJson, position, tocHref, title)?.let { items.add(it) } - fromJson(itemJson, position, title)?.let { items.addAll(it.items) } + fromJson(itemJson, position, tocHref, title)?.let { items.addAll(it.items) } } return FlutterMediaOverlay(items) diff --git a/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/models/FlutterMediaOverlayItem.kt b/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/models/FlutterMediaOverlayItem.kt index 0786a589..4ce587c4 100644 --- a/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/models/FlutterMediaOverlayItem.kt +++ b/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/models/FlutterMediaOverlayItem.kt @@ -25,6 +25,11 @@ data class FlutterMediaOverlayItem( */ val position: Int, + /** + * The ToC item for this item. + */ + val tocHref: Url?, + /** * The title of the chapter or section this item belongs to */ @@ -114,6 +119,7 @@ data class FlutterMediaOverlayItem( textLocator.copy( locations = textLocator.locations.copy( fragments = listOf("t=${audioStart ?: 0.0}"), + otherLocations = textLocator.locations.otherLocations + ("toc" to tocHref.toString()) ), ) } @@ -142,11 +148,11 @@ data class FlutterMediaOverlayItem( * Creates a [FlutterMediaOverlayItem] from a JSON object. * Returns null if the JSON object does not contain valid "audio" and "text" */ - fun fromJson(json: JSONObject, position: Int, title: String): FlutterMediaOverlayItem? { + fun fromJson(json: JSONObject, position: Int, tocHref: Url?, title: String): FlutterMediaOverlayItem? { val audio = json.optString("audio") val text = json.optString("text") return if (audio != "" && text != "") { - FlutterMediaOverlayItem(audio, text, position, title) + FlutterMediaOverlayItem(audio, text, position, tocHref, title) } else { null } diff --git a/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/models/PageInformation.kt b/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/models/PageInformation.kt new file mode 100644 index 00000000..d7d7263c --- /dev/null +++ b/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/models/PageInformation.kt @@ -0,0 +1,54 @@ +package dk.nota.flutter_readium.models + +import dk.nota.flutter_readium.cleanHref +import dk.nota.flutter_readium.jsonDecode +import org.json.JSONObject +import org.readium.r2.shared.InternalReadiumApi +import org.readium.r2.shared.extensions.optNullableString +import org.readium.r2.shared.util.Url + +class PageInformation( + val physicalPage: String?, + val cssSelector: String?, + val href: String, + val tocId: String? +) { + val otherLocations: Map + get() { + val res = mutableMapOf() + + physicalPage?.takeIf { it.isNotEmpty() }?.let { + res["physicalPage"] = it + } + + cssSelector?.takeIf { it.isNotEmpty() }?.let { + res["cssSelector"] = it + } + + tocId?.takeIf { it.isNotEmpty() }?.let { + res["tocId"] = it + } + return res; + } + + companion object { + fun fromJson(json: String, href: Url): PageInformation = + fromJson(jsonDecode(json) as JSONObject, href) + + @OptIn(InternalReadiumApi::class) + fun fromJson(json: JSONObject, href: Url): PageInformation { + val page = json.optLong("page") + val totalPages = json.optLong("totalPages") + val physicalPage = json.optString("physicalPage").takeIf { it.isNotEmpty() } + val cssSelector = json.optNullableString("cssSelector") + val tocId = json.optNullableString("tocId")?.takeIf { it.isNotEmpty() } + + return PageInformation( + physicalPage, + cssSelector, + href.cleanHref().toString(), + tocId + ) + } + } +} diff --git a/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/navigators/AudiobookNavigator.kt b/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/navigators/AudiobookNavigator.kt index dc06a246..f71a0619 100644 --- a/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/navigators/AudiobookNavigator.kt +++ b/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/navigators/AudiobookNavigator.kt @@ -7,7 +7,10 @@ import dk.nota.flutter_readium.FlutterAudioPreferences import dk.nota.flutter_readium.PluginMediaServiceFacade import dk.nota.flutter_readium.PublicationError import dk.nota.flutter_readium.ReadiumReader +import dk.nota.flutter_readium.cleanHref +import dk.nota.flutter_readium.flattenChildren import dk.nota.flutter_readium.throttleLatest +import dk.nota.flutter_readium.time import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow @@ -23,7 +26,10 @@ import org.readium.adapter.exoplayer.audio.ExoPlayerNavigatorFactory import org.readium.adapter.exoplayer.audio.ExoPlayerPreferences import org.readium.adapter.exoplayer.audio.ExoPlayerSettings import org.readium.navigator.media.audio.AudioNavigator +import org.readium.r2.navigator.extensions.time import org.readium.r2.shared.ExperimentalReadiumApi +import org.readium.r2.shared.InternalReadiumApi +import org.readium.r2.shared.publication.Link import org.readium.r2.shared.publication.Locator import org.readium.r2.shared.publication.Publication import org.readium.r2.shared.util.getOrElse @@ -65,8 +71,10 @@ open class AudiobookNavigator( DatabaseMediaMetadataFactory( publication = publication, trackCount = pub.readingOrder.size, - controlPanelInfoType = preferences.controlPanelInfoType ?: ControlPanelInfoType.STANDARD - )}) + controlPanelInfoType = preferences.controlPanelInfoType + ?: ControlPanelInfoType.STANDARD + ) + }) ) if (navigatorFactory == null) { @@ -225,6 +233,35 @@ open class AudiobookNavigator( } } + @OptIn(InternalReadiumApi::class) + override fun onCurrentLocatorChanges(locator: Locator) { + var emittingLocator = locator + + locator.locations.time?.let { time -> + var matchedTocItem: Link? = null + val cleanHref = locator.href.cleanHref().path + for (link in publication.tableOfContents.flattenChildren().filter { + it.href.resolve().cleanHref().path == cleanHref + }) { + val tocTime = link.href.time ?: continue + if (tocTime > time) { + break + } + matchedTocItem = link + } + + matchedTocItem?.href?.resolve()?.let { + emittingLocator = emittingLocator.copy( + locations = emittingLocator.locations.copy( + otherLocations = emittingLocator.locations.otherLocations + ("toc" to it) + ) + ) + } + } + + super.onCurrentLocatorChanges(emittingLocator) + } + override fun onPlaybackStateChanged(pb: AudioNavigator.Playback) { when (pb.state) { is AudioNavigator.State.Failure<*> -> { diff --git a/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/navigators/EpubNavigator.kt b/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/navigators/EpubNavigator.kt index 4f01acf7..07051e5d 100644 --- a/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/navigators/EpubNavigator.kt +++ b/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/navigators/EpubNavigator.kt @@ -6,9 +6,9 @@ import android.view.ViewGroup import androidx.fragment.app.FragmentManager import androidx.fragment.app.commitNow import dk.nota.flutter_readium.ReadiumReaderWidget.Companion.NAVIGATOR_FRAGMENT_TAG -import dk.nota.flutter_readium.canScroll +import dk.nota.flutter_readium.flattenChildren import dk.nota.flutter_readium.fragments.EpubReaderFragment -import dk.nota.flutter_readium.jsonDecode +import dk.nota.flutter_readium.jsonEncode import dk.nota.flutter_readium.models.EpubReaderViewModel import dk.nota.flutter_readium.throttleLatest import dk.nota.flutter_readium.withScope @@ -102,11 +102,6 @@ class EpubNavigator : BaseNavigator, EpubReaderFragment.Listener { */ private var editor: EpubPreferencesEditor? = null - /** - * Pending scroll target to be applied when the page is loaded. - */ - var pendingScrollToLocations: Locator.Locations? = null - /** * Current EPUB preferences. */ @@ -134,11 +129,6 @@ class EpubNavigator : BaseNavigator, EpubReaderFragment.Listener { } override suspend fun initNavigator() { - pendingScrollToLocations = - initialLocator?.locations?.let { locations -> - if (canScroll(locations)) locations else null - } - epubNavigator = EpubReaderFragment().apply { vm = EpubReaderViewModel().apply { navigatorFactory = EpubNavigatorFactory(publication) @@ -250,20 +240,22 @@ class EpubNavigator : BaseNavigator, EpubReaderFragment.Listener { } } - override fun onPageLoaded() { - Log.d(TAG, "::onPageLoaded") - visualListener.onPageLoaded() - - pendingScrollToLocations?.let { locations -> - Log.d(TAG, "::onPageLoaded - pendingScrollToLocations: $locations") + fun updateTocInJavascript() { + mainScope.launch { + val tocIds = + publication.tableOfContents.flattenChildren().map { it.href.resolve().fragment } - mainScope.async { - scrollToLocations(locations, toStart = true) + for (chunk in tocIds.chunked(1000)) { + evaluateJavascript("window.epubPage.registerToc(${jsonEncode(chunk)})") } + } + } - pendingScrollToLocations = null + override fun onPageLoaded() { + Log.d(TAG, "::onPageLoaded") + updateTocInJavascript() - } + visualListener.onPageLoaded() notifyIsReady() } @@ -354,30 +346,6 @@ class EpubNavigator : BaseNavigator, EpubReaderFragment.Listener { navigatorStarted.first { it } } - suspend fun getLocatorFragments(locator: Locator): Locator? { - val json = - evaluateJavascript("window.epubPage.getLocatorFragments(${locator.toJSON()}, $isVerticalScroll)") - try { - if (json == null || json == "null" || json == "undefined") { - Log.e( - TAG, - "getLocatorFragments: window.epubPage.getVisibleRange failed!" - ) - return null - } - val jsonLocator = jsonDecode(json) as JSONObject - val locatorWithFragments = Locator.fromJSON(jsonLocator) - - return locatorWithFragments - } catch (e: Exception) { - Log.e( - TAG, - "getLocatorFragments: window.epubPage.getVisibleRange json: $json failed! $e" - ) - } - return null - } - suspend fun firstVisibleElementLocator(): Locator? { val navigator = epubNavigator if (navigator == null) { @@ -399,41 +367,11 @@ class EpubNavigator : BaseNavigator, EpubReaderFragment.Listener { }.await() } - private suspend fun scrollToLocations( - locations: Locator.Locations, - toStart: Boolean - ) { - val json = locations.toJSON().toString() - Log.d(TAG, "::scrollToLocations: Go to locations $json, toStart: $toStart") - evaluateJavascript("window.epubPage.scrollToLocations($json,$isVerticalScroll,$toStart);") - } - /** * Go to a specific locator in the EPUB navigator, this scrolls to the locator position if needed. */ suspend fun goToLocator(locator: Locator, animated: Boolean) { - mainScope.async { - val locations = locator.locations - val shouldScroll = canScroll(locations) - val locatorHref = locator.href - val currentHref = currentLocator?.value?.href - val shouldGo = currentHref?.isEquivalent(locatorHref) == false - - // TODO: Figure out why we can't just use rely on Readium's own go-function to scroll - // the locator. - if (shouldGo) { - Log.d(TAG, "::goToLocator: Go to $locatorHref from $currentHref") - pendingScrollToLocations = locations - go(locator, animated) - } else if (!shouldScroll) { - Log.w(TAG, "::goToLocator: Already at $locatorHref, no scroll target, go to start") - scrollToLocations(Locator.Locations(progression = 0.0), true) - } else { - Log.d(TAG, "::goToLocator: Already at $locatorHref, scroll to position") - - scrollToLocations(locations, false) - } - }.await() + go(locator, animated) } companion object { diff --git a/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/navigators/SyncAudiobookNavigator.kt b/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/navigators/SyncAudiobookNavigator.kt index 1353b720..be9776d5 100644 --- a/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/navigators/SyncAudiobookNavigator.kt +++ b/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/navigators/SyncAudiobookNavigator.kt @@ -67,7 +67,7 @@ class SyncAudiobookNavigator( if (mediaOverlay == null) { Log.d( TAG, - ":onTimebasedCurrentLocatorChanges no media-overlay item found for locator=$locator, timeOffset=$timeOffset" + ":onCurrentLocatorChanges no media-overlay item found for locator=$locator, timeOffset=$timeOffset" ) return } diff --git a/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/navigators/TTSNavigator.kt b/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/navigators/TTSNavigator.kt index 0748c538..9ad0f975 100644 --- a/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/navigators/TTSNavigator.kt +++ b/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/navigators/TTSNavigator.kt @@ -301,8 +301,10 @@ class TTSNavigator( .throttleLatest(100.milliseconds) .distinctUntilChanged() .onEach { locator -> - onCurrentLocatorChanges(locator) - state[currentTimebasedLocatorKey] = locator + val emittingLocator = + ReadiumReader.epubFindCurrentToc(locator) + onCurrentLocatorChanges(emittingLocator) + state[currentTimebasedLocatorKey] = emittingLocator } .launchIn(mainScope) .let { jobs.add(it) } diff --git a/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/navigators/TimebasedNavigator.kt b/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/navigators/TimebasedNavigator.kt index 56ff9e25..6b4b8b0d 100644 --- a/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/navigators/TimebasedNavigator.kt +++ b/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/navigators/TimebasedNavigator.kt @@ -75,7 +75,8 @@ abstract class TimebasedNavigator

( var timebasedState: TimebasedState when (pb.state) { is MediaNavigator.State.Ready -> { - timebasedState = if (pb.playWhenReady) TimebasedState.Playing else TimebasedState.Paused + timebasedState = + if (pb.playWhenReady) TimebasedState.Playing else TimebasedState.Paused } is MediaNavigator.State.Buffering -> { @@ -100,26 +101,24 @@ abstract class TimebasedNavigator

( } override fun onCurrentLocatorChanges(locator: Locator) { + var emittingLocator = locator + val readingOrderLink = publication.readingOrder.find { link -> link.href.toString() == locator.href.toString() } - if (locator.locations.position == null) { - val index = - publication.readingOrder.indexOfFirst { link -> - link == readingOrderLink - } - if (index != -1) { - val newLocator = locator.copy( + if (emittingLocator.locations.position == null) { + publication.readingOrder.indexOfFirst { link -> + link == readingOrderLink + }.takeIf { it > -1 }?.let { index -> + emittingLocator = emittingLocator.copy( locations = locator.locations.copy(position = index + 1) ) - timebaseListener.onTimebasedCurrentLocatorChanges(newLocator, readingOrderLink) - return } } - timebaseListener.onTimebasedCurrentLocatorChanges(locator, readingOrderLink) + timebaseListener.onTimebasedCurrentLocatorChanges(emittingLocator, readingOrderLink) } /** diff --git a/flutter_readium/assets/_helper_scripts/package-lock.json b/flutter_readium/assets/_helper_scripts/package-lock.json index 63d5def3..64c9e309 100644 --- a/flutter_readium/assets/_helper_scripts/package-lock.json +++ b/flutter_readium/assets/_helper_scripts/package-lock.json @@ -9,19 +9,18 @@ "version": "1.0.0", "dependencies": { "animejs": "~3.2.2", - "css-selector-generator": "~3.6.9", - "lit": "~3.3.0", + "lit": "~3.3.2", "readium-css": "github:readium/readium-css" }, "devDependencies": { "@types/animejs": "^3.1.13", "@types/jest": "^29.5.14", - "@typescript-eslint/eslint-plugin": "^8.32.1", + "@typescript-eslint/eslint-plugin": "^8.56.1", "@typescript-eslint/parser": "^8.32.1", "babel-plugin-syntax-dynamic-import": "^6.18.0", - "copy-webpack-plugin": "^13.0.0", + "copy-webpack-plugin": "^14.0.0", "cross-env": "^7.0.3", - "css-loader": "^7.1.2", + "css-loader": "^7.1.4", "eslint": "^9.27.0", "eslint-config-prettier": "^10.1.5", "eslint-plugin-import": "^2.31.0", @@ -29,18 +28,18 @@ "eslint-plugin-prefer-arrow": "^1.2.3", "eslint-plugin-prettier": "^5.4.0", "file-loader": "^6.2.0", - "html-webpack-plugin": "^5.6.3", + "html-webpack-plugin": "^5.6.6", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", - "mini-css-extract-plugin": "^2.9.2", - "sass": "^1.89.0", - "sass-loader": "^16.0.5", - "terser-webpack-plugin": "^5.3.14", + "mini-css-extract-plugin": "^2.10.0", + "sass": "^1.97.3", + "sass-loader": "^16.0.7", + "terser-webpack-plugin": "^5.3.17", "ts-jest": "^29.3.4", - "ts-loader": "^9.5.2", + "ts-loader": "^9.5.4", "ts-node": "^10.9.2", - "typescript": "^5.8.3", - "webpack": "^5.105.0", + "typescript": "^5.9.3", + "webpack": "^5.105.4", "webpack-cli": "^6.0.1", "webpack-merge": "^6.0.1" } @@ -560,10 +559,11 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", - "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", "dev": true, + "license": "MIT", "dependencies": { "eslint-visitor-keys": "^3.4.3" }, @@ -578,10 +578,11 @@ } }, "node_modules/@eslint-community/regexpp": { - "version": "4.12.1", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", - "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", "dev": true, + "license": "MIT", "engines": { "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } @@ -612,10 +613,11 @@ } }, "node_modules/@eslint/config-array/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -707,10 +709,11 @@ } }, "node_modules/@eslint/eslintrc/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -740,18 +743,32 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.1.tgz", - "integrity": "sha512-0J+zgWxHN+xXONWIyPWKFMgVuJoZuGiIFu8yxk7RJjxkzpGmyja5wRFqZIVtjDVOQpV+Rw0iOAjYPE2eQyjr0w==", + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz", + "integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==", "dev": true, + "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.14.0", + "@eslint/core": "^0.15.2", "levn": "^0.4.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@eslint/plugin-kit/node_modules/@eslint/core": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz", + "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1248,41 +1265,6 @@ "@lit-labs/ssr-dom-shim": "^1.2.0" } }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/@parcel/watcher": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", @@ -1842,20 +1824,20 @@ "dev": true }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.32.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.32.1.tgz", - "integrity": "sha512-6u6Plg9nP/J1GRpe/vcjjabo6Uc5YQPAMxsgQyGC/I0RuukiG1wIe3+Vtg3IrSCVJDmqK3j8adrtzXSENRtFgg==", - "dev": true, - "dependencies": { - "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.32.1", - "@typescript-eslint/type-utils": "8.32.1", - "@typescript-eslint/utils": "8.32.1", - "@typescript-eslint/visitor-keys": "8.32.1", - "graphemer": "^1.4.0", - "ignore": "^7.0.0", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz", + "integrity": "sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.56.1", + "@typescript-eslint/type-utils": "8.56.1", + "@typescript-eslint/utils": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1", + "ignore": "^7.0.5", "natural-compare": "^1.4.0", - "ts-api-utils": "^2.1.0" + "ts-api-utils": "^2.4.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1865,22 +1847,46 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" + "@typescript-eslint/parser": "^8.56.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/parser": { - "version": "8.32.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.32.1.tgz", - "integrity": "sha512-LKMrmwCPoLhM45Z00O1ulb6jwyVr2kr3XJp+G+tSEZcbauNnScewcQwtJqXDhXeYPDEjZ8C1SjXm015CirEmGg==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.1.tgz", + "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.56.1", + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.1.tgz", + "integrity": "sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ==", "dev": true, + "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.32.1", - "@typescript-eslint/types": "8.32.1", - "@typescript-eslint/typescript-estree": "8.32.1", - "@typescript-eslint/visitor-keys": "8.32.1", - "debug": "^4.3.4" + "@typescript-eslint/tsconfig-utils": "^8.56.1", + "@typescript-eslint/types": "^8.56.1", + "debug": "^4.4.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1890,37 +1896,56 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.32.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.32.1.tgz", - "integrity": "sha512-7IsIaIDeZn7kffk7qXC3o6Z4UblZJKV3UBpkvRNpr5NSyLji7tvTcvmnMNYuYLyh26mN8W723xpo3i4MlD33vA==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.56.1.tgz", + "integrity": "sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==", "dev": true, + "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.32.1", - "@typescript-eslint/visitor-keys": "8.32.1" + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.1.tgz", + "integrity": "sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ==", + "dev": true, + "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.32.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.32.1.tgz", - "integrity": "sha512-mv9YpQGA8iIsl5KyUPi+FGLm7+bA4fgXaeRcFKRDRwDMu4iwrSHeDPipwueNXhdIIZltwCJv+NkxftECbIZWfA==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.56.1.tgz", + "integrity": "sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg==", "dev": true, + "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "8.32.1", - "@typescript-eslint/utils": "8.32.1", - "debug": "^4.3.4", - "ts-api-utils": "^2.1.0" + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1", + "@typescript-eslint/utils": "8.56.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1930,15 +1955,16 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/types": { - "version": "8.32.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.32.1.tgz", - "integrity": "sha512-YmybwXUJcgGqgAp6bEsgpPXEg6dcCyPyCSr0CAAueacR/CCBi25G3V8gGQ2kRzQRBNol7VQknxMs9HvVa9Rvfg==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.1.tgz", + "integrity": "sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==", "dev": true, + "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -1948,19 +1974,21 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.32.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.32.1.tgz", - "integrity": "sha512-Y3AP9EIfYwBb4kWGb+simvPaqQoT5oJuzzj9m0i6FCY6SPvlomY2Ei4UEMm7+FXtlNJbor80ximyslzaQF6xhg==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.1.tgz", + "integrity": "sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg==", "dev": true, + "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.32.1", - "@typescript-eslint/visitor-keys": "8.32.1", - "debug": "^4.3.4", - "fast-glob": "^3.3.2", - "is-glob": "^4.0.3", - "minimatch": "^9.0.4", - "semver": "^7.6.0", - "ts-api-utils": "^2.1.0" + "@typescript-eslint/project-service": "8.56.1", + "@typescript-eslint/tsconfig-utils": "8.56.1", + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1970,19 +1998,20 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/utils": { - "version": "8.32.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.32.1.tgz", - "integrity": "sha512-DsSFNIgLSrc89gpq1LJB7Hm1YpuhK086DRDJSNrewcGvYloWW1vZLHBTIvarKZDcAORIy/uWNx8Gad+4oMpkSA==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.1.tgz", + "integrity": "sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==", "dev": true, + "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.32.1", - "@typescript-eslint/types": "8.32.1", - "@typescript-eslint/typescript-estree": "8.32.1" + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.56.1", + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1992,18 +2021,19 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.32.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.32.1.tgz", - "integrity": "sha512-ar0tjQfObzhSaW3C3QNmTc5ofj0hDoNQ5XWrCy6zDyabdr0TWhCkClp+rywGNj/odAFBVzzJrK4tEq5M4Hmu4w==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.1.tgz", + "integrity": "sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==", "dev": true, + "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.32.1", - "eslint-visitor-keys": "^4.2.0" + "@typescript-eslint/types": "8.56.1", + "eslint-visitor-keys": "^5.0.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2014,12 +2044,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", - "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, + "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" @@ -2235,9 +2266,9 @@ "dev": true }, "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", "bin": { @@ -2304,10 +2335,11 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, + "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -2337,10 +2369,11 @@ } }, "node_modules/ajv-formats/node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, + "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -3112,19 +3145,20 @@ "dev": true }, "node_modules/copy-webpack-plugin": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-13.0.0.tgz", - "integrity": "sha512-FgR/h5a6hzJqATDGd9YG41SeDViH+0bkHn6WNXCi5zKAZkeESeSxLySSsFLHqLEVCh0E+rITmCf0dusXWYukeQ==", + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-14.0.0.tgz", + "integrity": "sha512-3JLW90aBGeaTLpM7mYQKpnVdgsUZRExY55giiZgLuX/xTQRUs1dOCwbBnWnvY6Q6rfZoXMNwzOQJCSZPppfqXA==", "dev": true, + "license": "MIT", "dependencies": { "glob-parent": "^6.0.1", "normalize-path": "^3.0.0", "schema-utils": "^4.2.0", - "serialize-javascript": "^6.0.2", + "serialize-javascript": "^7.0.3", "tinyglobby": "^0.2.12" }, "engines": { - "node": ">= 18.12.0" + "node": ">= 20.9.0" }, "funding": { "type": "opencollective", @@ -3194,19 +3228,20 @@ } }, "node_modules/css-loader": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-7.1.2.tgz", - "integrity": "sha512-6WvYYn7l/XEGN8Xu2vWFt9nVzrCn39vKyTEFf/ExEyoksJjjSZV/0/35XPlMbpnr6VGhZIUg5yJrL8tGfes/FA==", + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-7.1.4.tgz", + "integrity": "sha512-vv3J9tlOl04WjiMvHQI/9tmIrCxVrj6PFbHemBB1iihpeRbi/I4h033eoFIhwxBBqLhI0KYFS7yvynBFhIZfTw==", "dev": true, + "license": "MIT", "dependencies": { "icss-utils": "^5.1.0", - "postcss": "^8.4.33", + "postcss": "^8.4.40", "postcss-modules-extract-imports": "^3.1.0", "postcss-modules-local-by-default": "^4.0.5", "postcss-modules-scope": "^3.2.0", "postcss-modules-values": "^4.0.0", "postcss-value-parser": "^4.2.0", - "semver": "^7.5.4" + "semver": "^7.6.3" }, "engines": { "node": ">= 18.12.0" @@ -3216,7 +3251,7 @@ "url": "https://opencollective.com/webpack" }, "peerDependencies": { - "@rspack/core": "0.x || 1.x", + "@rspack/core": "0.x || ^1.0.0 || ^2.0.0-0", "webpack": "^5.27.0" }, "peerDependenciesMeta": { @@ -3244,11 +3279,6 @@ "url": "https://github.com/sponsors/fb55" } }, - "node_modules/css-selector-generator": { - "version": "3.6.9", - "resolved": "https://registry.npmjs.org/css-selector-generator/-/css-selector-generator-3.6.9.tgz", - "integrity": "sha512-OXV+a4wlKs+8TGxTZ8g96mQOKz5QDVE52QYdYusdbcmt0XkJd6F9zkXpMZbRk3pwwRF+K2pJkj1DINpWm7Isqw==" - }, "node_modules/css-what": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", @@ -3363,12 +3393,13 @@ } }, "node_modules/debug": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz", - "integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "dev": true, + "license": "MIT", "dependencies": { - "ms": "2.1.2" + "ms": "^2.1.3" }, "engines": { "node": ">=6.0" @@ -3480,10 +3511,11 @@ } }, "node_modules/diff": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", - "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", "dev": true, + "license": "BSD-3-Clause", "engines": { "node": ">=0.3.1" } @@ -3658,9 +3690,9 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.19.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz", - "integrity": "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==", + "version": "5.20.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz", + "integrity": "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4074,10 +4106,11 @@ } }, "node_modules/eslint-plugin-import/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -4218,10 +4251,11 @@ } }, "node_modules/eslint/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -4382,34 +4416,6 @@ "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", "dev": true }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -4437,15 +4443,6 @@ "node": ">= 4.9.1" } }, - "node_modules/fastq": { - "version": "1.19.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", - "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", - "dev": true, - "dependencies": { - "reusify": "^1.0.4" - } - }, "node_modules/fb-watchman": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", @@ -4515,10 +4512,11 @@ } }, "node_modules/filelist/node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" }, @@ -4811,10 +4809,11 @@ } }, "node_modules/glob/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -4869,12 +4868,6 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "dev": true }, - "node_modules/graphemer": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true - }, "node_modules/has-bigints": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz", @@ -5006,10 +4999,11 @@ } }, "node_modules/html-webpack-plugin": { - "version": "5.6.3", - "resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-5.6.3.tgz", - "integrity": "sha512-QSf1yjtSAsmf7rYBV7XX86uua4W/vkhIt0xNXKbsi2foEeW7vjJQz4bhnpL3xH+l1ryl1680uNv968Z+X6jSYg==", + "version": "5.6.6", + "resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-5.6.6.tgz", + "integrity": "sha512-bLjW01UTrvoWTJQL5LsMRo1SypHW80FTm12OJRSnr3v6YHNhfe+1r0MYUZJMACxnCHURVnBWRwAsWs2yPU9Ezw==", "dev": true, + "license": "MIT", "dependencies": { "@types/html-minifier-terser": "^6.0.0", "html-minifier-terser": "^6.0.2", @@ -5114,10 +5108,11 @@ } }, "node_modules/ignore": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.4.tgz", - "integrity": "sha512-gJzzk+PQNznz8ysRrC0aOkBNVRBDtE1n53IqyqEf3PXrYwomFs5q4pGMizBMJF+ykh03insJ27hB8gSrD2Hn8A==", + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", "dev": true, + "license": "MIT", "engines": { "node": ">= 4" } @@ -5629,10 +5624,11 @@ } }, "node_modules/jake/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -6401,9 +6397,10 @@ "dev": true }, "node_modules/lit": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/lit/-/lit-3.3.0.tgz", - "integrity": "sha512-DGVsqsOIHBww2DqnuZzW7QsuCdahp50ojuDaBPC7jUDRpYoH0z7kHBBYZewRzer75FwtrkmkKk7iOAwSaWdBmw==", + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/lit/-/lit-3.3.2.tgz", + "integrity": "sha512-NF9zbsP79l4ao2SNrH3NkfmFgN/hBYSQo90saIVI1o5GpjAdCPVstVzO1MrLOakHoEhYkrtRjPK6Ob521aoYWQ==", + "license": "BSD-3-Clause", "dependencies": { "@lit/reactive-element": "^2.1.0", "lit-element": "^4.2.0", @@ -6554,15 +6551,6 @@ "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", "dev": true }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, - "engines": { - "node": ">= 8" - } - }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -6607,10 +6595,11 @@ } }, "node_modules/mini-css-extract-plugin": { - "version": "2.9.2", - "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.9.2.tgz", - "integrity": "sha512-GJuACcS//jtq4kCtd5ii/M0SZf7OZRH+BxdqXZHaJfb8TJiVl+NgQRPwiYt2EuqeSkNydn/7vP+bcE27C5mb9w==", + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.10.0.tgz", + "integrity": "sha512-540P2c5dYnJlyJxTaSloliZexv8rji6rY8FhQN+WF/82iHQfA23j/xtJx97L+mXOML27EqksSek/g4eK7jaL3g==", "dev": true, + "license": "MIT", "dependencies": { "schema-utils": "^4.0.0", "tapable": "^2.2.1" @@ -6627,20 +6616,44 @@ } }, "node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", "dev": true, + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^5.0.2" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/minimatch/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/minimatch/node_modules/brace-expansion": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/minimist": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", @@ -6651,10 +6664,11 @@ } }, "node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" }, "node_modules/nanoid": { "version": "3.3.11", @@ -7363,35 +7377,6 @@ "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", "dev": true }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/randombytes": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", - "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", - "dev": true, - "dependencies": { - "safe-buffer": "^5.1.0" - } - }, "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", @@ -7548,39 +7533,6 @@ "node": ">=10" } }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true, - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, "node_modules/safe-array-concat": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.2.tgz", @@ -7599,26 +7551,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, "node_modules/safe-regex-test": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.3.tgz", @@ -7643,10 +7575,11 @@ "dev": true }, "node_modules/sass": { - "version": "1.89.0", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.89.0.tgz", - "integrity": "sha512-ld+kQU8YTdGNjOLfRWBzewJpU5cwEv/h5yyqlSeJcj6Yh8U4TDA9UA5FPicqDz/xgRPWRSYIQNiFks21TbA9KQ==", + "version": "1.97.3", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.97.3.tgz", + "integrity": "sha512-fDz1zJpd5GycprAbu4Q2PV/RprsRtKC/0z82z0JLgdytmcq0+ujJbJ/09bPGDxCLkKY3Np5cRAOcWiVkLXJURg==", "dev": true, + "license": "MIT", "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.0.2", @@ -7663,10 +7596,11 @@ } }, "node_modules/sass-loader": { - "version": "16.0.5", - "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-16.0.5.tgz", - "integrity": "sha512-oL+CMBXrj6BZ/zOq4os+UECPL+bWqt6OAC6DWS8Ln8GZRcMDjlJ4JC3FBDuHJdYaFWIdKNIBYmtZtK2MaMkNIw==", + "version": "16.0.7", + "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-16.0.7.tgz", + "integrity": "sha512-w6q+fRHourZ+e+xA1kcsF27iGM6jdB8teexYCfdUw0sYgcDNeZESnDNT9sUmmPm3ooziwUJXGwZJSTF3kOdBfA==", "dev": true, + "license": "MIT", "dependencies": { "neo-async": "^2.6.2" }, @@ -7678,7 +7612,7 @@ "url": "https://opencollective.com/webpack" }, "peerDependencies": { - "@rspack/core": "0.x || 1.x", + "@rspack/core": "0.x || ^1.0.0 || ^2.0.0-0", "node-sass": "^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0", "sass": "^1.3.0", "sass-embedded": "*", @@ -7735,10 +7669,11 @@ } }, "node_modules/schema-utils/node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, + "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -7769,10 +7704,11 @@ "dev": true }, "node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "dev": true, + "license": "ISC", "bin": { "semver": "bin/semver.js" }, @@ -7781,12 +7717,13 @@ } }, "node_modules/serialize-javascript": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", - "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.0.4.tgz", + "integrity": "sha512-DuGdB+Po43Q5Jxwpzt1lhyFSYKryqoNjQSA9M92tyw0lyHIOur+XCalOUe0KTJpyqzT8+fQ5A0Jf7vCx/NKmIg==", "dev": true, - "dependencies": { - "randombytes": "^2.1.0" + "license": "BSD-3-Clause", + "engines": { + "node": ">=20.0.0" } }, "node_modules/set-function-length": { @@ -8166,16 +8103,15 @@ } }, "node_modules/terser-webpack-plugin": { - "version": "5.3.16", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.16.tgz", - "integrity": "sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q==", + "version": "5.3.17", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.17.tgz", + "integrity": "sha512-YR7PtUp6GMU91BgSJmlaX/rS2lGDbAF7D+Wtq7hRO+MiljNmodYvqslzCFiYVAgW+Qoaaia/QUIP4lGXufjdZw==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "jest-worker": "^27.4.5", "schema-utils": "^4.3.0", - "serialize-javascript": "^6.0.2", "terser": "^5.31.1" }, "engines": { @@ -8271,10 +8207,11 @@ } }, "node_modules/test-exclude/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -8283,13 +8220,14 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.14", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", - "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", "dev": true, + "license": "MIT", "dependencies": { - "fdir": "^6.4.4", - "picomatch": "^4.0.2" + "fdir": "^6.5.0", + "picomatch": "^4.0.3" }, "engines": { "node": ">=12.0.0" @@ -8299,10 +8237,14 @@ } }, "node_modules/tinyglobby/node_modules/fdir": { - "version": "6.4.4", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz", - "integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==", + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, "peerDependencies": { "picomatch": "^3 || ^4" }, @@ -8313,10 +8255,11 @@ } }, "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", - "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, + "license": "MIT", "engines": { "node": ">=12" }, @@ -8370,10 +8313,11 @@ } }, "node_modules/ts-api-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", - "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", "dev": true, + "license": "MIT", "engines": { "node": ">=18.12" }, @@ -8431,10 +8375,11 @@ } }, "node_modules/ts-loader": { - "version": "9.5.2", - "resolved": "https://registry.npmjs.org/ts-loader/-/ts-loader-9.5.2.tgz", - "integrity": "sha512-Qo4piXvOTWcMGIgRiuFa6nHNm+54HbYaZCKqc9eeZCLRy3XqafQgwX2F7mofrbJG3g7EEb+lkiR+z2Lic2s3Zw==", + "version": "9.5.4", + "resolved": "https://registry.npmjs.org/ts-loader/-/ts-loader-9.5.4.tgz", + "integrity": "sha512-nCz0rEwunlTZiy6rXFByQU1kVVpCIgUpc/psFiKVrUwrizdnIbRFu8w7bxhUF0X613DYwT4XzrZHpVyMe758hQ==", "dev": true, + "license": "MIT", "dependencies": { "chalk": "^4.1.0", "enhanced-resolve": "^5.0.0", @@ -8648,10 +8593,11 @@ } }, "node_modules/typescript": { - "version": "5.8.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", - "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, + "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -8817,9 +8763,9 @@ } }, "node_modules/webpack": { - "version": "5.105.0", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.0.tgz", - "integrity": "sha512-gX/dMkRQc7QOMzgTe6KsYFM7DxeIONQSui1s0n/0xht36HvrgbxtM1xBlgx596NbpHuQU8P7QpKwrZYwUX48nw==", + "version": "5.105.4", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.4.tgz", + "integrity": "sha512-jTywjboN9aHxFlToqb0K0Zs9SbBoW4zRUlGzI2tYNxVYcEi/IPpn+Xi4ye5jTLvX2YeLuic/IvxNot+Q1jMoOw==", "dev": true, "license": "MIT", "dependencies": { @@ -8829,11 +8775,11 @@ "@webassemblyjs/ast": "^1.14.1", "@webassemblyjs/wasm-edit": "^1.14.1", "@webassemblyjs/wasm-parser": "^1.14.1", - "acorn": "^8.15.0", + "acorn": "^8.16.0", "acorn-import-phases": "^1.0.3", "browserslist": "^4.28.1", "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.19.0", + "enhanced-resolve": "^5.20.0", "es-module-lexer": "^2.0.0", "eslint-scope": "5.1.1", "events": "^3.2.0", @@ -8845,9 +8791,9 @@ "neo-async": "^2.6.2", "schema-utils": "^4.3.3", "tapable": "^2.3.0", - "terser-webpack-plugin": "^5.3.16", + "terser-webpack-plugin": "^5.3.17", "watchpack": "^2.5.1", - "webpack-sources": "^3.3.3" + "webpack-sources": "^3.3.4" }, "bin": { "webpack": "bin/webpack.js" @@ -8932,9 +8878,9 @@ } }, "node_modules/webpack-sources": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.3.tgz", - "integrity": "sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==", + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.4.tgz", + "integrity": "sha512-7tP1PdV4vF+lYPnkMR0jMY5/la2ub5Fc/8VQrrU+lXkiM6C4TjVfGw7iKfyhnTQOsD+6Q/iKw0eFciziRgD58Q==", "dev": true, "license": "MIT", "engines": { diff --git a/flutter_readium/assets/_helper_scripts/package.json b/flutter_readium/assets/_helper_scripts/package.json index 597479b3..be92c5ae 100644 --- a/flutter_readium/assets/_helper_scripts/package.json +++ b/flutter_readium/assets/_helper_scripts/package.json @@ -12,12 +12,12 @@ "devDependencies": { "@types/animejs": "^3.1.13", "@types/jest": "^29.5.14", - "@typescript-eslint/eslint-plugin": "^8.32.1", + "@typescript-eslint/eslint-plugin": "^8.56.1", "@typescript-eslint/parser": "^8.32.1", "babel-plugin-syntax-dynamic-import": "^6.18.0", - "copy-webpack-plugin": "^13.0.0", + "copy-webpack-plugin": "^14.0.0", "cross-env": "^7.0.3", - "css-loader": "^7.1.2", + "css-loader": "^7.1.4", "eslint": "^9.27.0", "eslint-config-prettier": "^10.1.5", "eslint-plugin-import": "^2.31.0", @@ -25,18 +25,18 @@ "eslint-plugin-prefer-arrow": "^1.2.3", "eslint-plugin-prettier": "^5.4.0", "file-loader": "^6.2.0", - "html-webpack-plugin": "^5.6.3", + "html-webpack-plugin": "^5.6.6", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", - "mini-css-extract-plugin": "^2.9.2", - "sass": "^1.89.0", - "sass-loader": "^16.0.5", - "terser-webpack-plugin": "^5.3.14", + "mini-css-extract-plugin": "^2.10.0", + "sass": "^1.97.3", + "sass-loader": "^16.0.7", + "terser-webpack-plugin": "^5.3.17", "ts-jest": "^29.3.4", - "ts-loader": "^9.5.2", + "ts-loader": "^9.5.4", "ts-node": "^10.9.2", - "typescript": "^5.8.3", - "webpack": "^5.105.0", + "typescript": "^5.9.3", + "webpack": "^5.105.4", "webpack-cli": "^6.0.1", "webpack-merge": "^6.0.1" }, @@ -51,8 +51,7 @@ }, "dependencies": { "animejs": "~3.2.2", - "css-selector-generator": "~3.6.9", - "lit": "~3.3.0", + "lit": "~3.3.2", "readium-css": "github:readium/readium-css" } } diff --git a/flutter_readium/assets/_helper_scripts/src/EpubPage.scss b/flutter_readium/assets/_helper_scripts/src/EpubPage.scss index 5de97c61..4aede2f6 100644 --- a/flutter_readium/assets/_helper_scripts/src/EpubPage.scss +++ b/flutter_readium/assets/_helper_scripts/src/EpubPage.scss @@ -10,12 +10,6 @@ font-size: 98%; } -span#activeLocation { - border-radius: 4px; - background-color: var(--USER__highlightBackgroundColor) !important; - color: var(--USER__highlightForegroundColor) !important; -} - body > *:first-child { margin-top: 50px !important; } diff --git a/flutter_readium/assets/_helper_scripts/src/EpubPage.ts b/flutter_readium/assets/_helper_scripts/src/EpubPage.ts index 78ccbfbc..8fd98bbb 100644 --- a/flutter_readium/assets/_helper_scripts/src/EpubPage.ts +++ b/flutter_readium/assets/_helper_scripts/src/EpubPage.ts @@ -1,945 +1,347 @@ -/* eslint-disable @typescript-eslint/restrict-template-expressions */ - -import { ComicBookPage } from 'ComicBookPage'; -import { getCssSelector } from 'css-selector-generator'; import { initResponsiveTables } from './Tables'; -import { DomRange, ICurrentHeading, IHeadingElement, Locations, Locator, Readium, Rect } from 'types'; +import { PageInformation, Readium } from 'types'; import './EpubPage.scss'; -declare const isIos: boolean; -declare const isAndroid: boolean; -declare const webkit: any; declare const readium: Readium; -declare const comicBookPage: ComicBookPage; -declare const Android: any | null; export class EpubPage { - private readonly _headingTagNames = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']; - - private _allHeadings: IHeadingElement[] | null; - - private readonly _activeLocationId = 'activeLocation'; - - private readonly _locationTag = 'span'; - - private _documentRange = document.createRange(); - - // Sets an active location, and optionally navigates to a specific frame in a comic book - public setLocation(locator: Locator | null, isAudioBookWithText: boolean): void { - this._debugLog(locator); - - try { - if (locator == null) { - this._debugLog('No locator set'); - - return; - } - - this._removeLocation(); - - this._setLocation(locator, isAudioBookWithText); - - if (this._isComicBook()) { - const cssSelector = - locator?.locations?.cssSelector ?? locator?.locations?.domRange?.start?.cssSelector ?? locator?.locations?.domRange?.end?.cssSelector; - - if (cssSelector == null) { - this._errorLog('Css selector not set!'); - return; - } - - const duration = this._getDurationFragment(locator?.locations?.fragments); - if (duration == null) { - this._errorLog('Duration not set!'); - return; - } - - window.GotoComicFrame(cssSelector, duration * 1000); - } - - // this.debugLog(`setAll END`); - } catch (error) { - this._errorLog(error); - } + get #isScrollModeEnabled(): boolean { + return readium.isReflowable === true && getComputedStyle(document.documentElement).getPropertyValue('--USER__view')?.trim() === 'readium-scroll-on"'; } - // Scrolls such that the given Locations is visible. If part of the given Locations is - // already visible, only scrolls to the start of it if toStart is true. - public scrollToLocations(locations: Locations, isVerticalScroll: boolean, toStart: boolean): boolean { - try { - const range = this._processLocations(locations); - if (range != null) { - this._scrollToProcessedRange(range, isVerticalScroll, toStart); + /** + * List of all ids from the publication's Table of Contents, in lowercase for easier comparison. + * This is used to find the nearest ToC element to the current reading position. + */ + #tocIds: string[] = []; - return true; - } - - const progression = locations.progression; - if (progression != null) { - readium?.scrollToPosition(progression); - - return true; - } - - this._debugLog(`ScrollToLocations: Unknown range`, locations); - } catch (error) { - this._errorLog(error); - } - - return false; - } - - // Checks whether a given locator is (at least partially) visible. - public isLocatorVisible(locator: Locator): boolean { - this._debugLog(locator); - - try { - const locations = locator.locations; - const selector = locations.cssSelector ?? locations.domRange?.start?.cssSelector; - - if (this._isComicBook()) { - const res = document.querySelector(selector) != null; - this._debugLog(`Comic book`, locations, { found: res, selector }); - - return res; - } - - const range = this._processLocations(locations); - if (range == null) { - this._debugLog(`isLocatorVisible: Unknown range`, locations); - return false; - } - // Checks also that the locator also contains `active` class. - // TODO: This doesn't do what we expect, if the range is visible but not active, this function will return false. - return this._isProcessedRangeVisible(range) && !!document.querySelector(`${selector} #${this._activeLocationId}`); - } catch (error) { - this._errorLog(error); - - // Use true as default to prevent showing the sync button. - return true; - } + /** + * Register all the ToC ids for the publication. Should be called in the EpubNavigator's onPageLoaded() callback. + */ + public registerToc(ids: string[]) { + this.#tocIds = this.#tocIds.concat(ids.map((id) => document.getElementById(id)?.id?.toLocaleLowerCase()).filter((id): id is string => id != null)); + console.error(`Registered ToC ids: ${this.#tocIds.join(", ")}`); } - // Returns fragments for current location. - public getLocatorFragments(locator: Locator, isVerticalScroll: boolean): Locator { - try { - const cssSelector = locator?.locations?.cssSelector ?? this._findFirstVisibleCssSelector(); - if (cssSelector == null || !cssSelector?.length) { - this._debugLog('getLocatorFragments: selector not found, returning locator from args'); - - return locator; - } - - const fragments = [...this._getPageFragments(isVerticalScroll), ...this._getTocFragments(cssSelector), ...this._getPhysicalPageFragments(cssSelector)]; - - const locatorWithFragments = { - ...locator, - locations: { - cssSelector, - ...locator.locations, - fragments: [...(locator.locations?.fragments ?? []), ...fragments], - }, - }; - - return locatorWithFragments; - } catch (error) { - this._errorLog(error); - - return locator; - } - } - - private _isComicBook(): boolean { - try { - return !!comicBookPage?.isComicBook(); - } catch (_) { - return false; - } - } - - private _isTextNode(node: Node) { - // const TEXT_NODE = 3; - // const CDATA_SECTION_NODE = 4; - const nodeType = node.nodeType; - return nodeType === 3 || nodeType === 4; - } - - // private - private _clamp(value: number, min: number, max: number) { - return Math.min(Math.max(value, min), max); - } - - // Returns the `Text` node starting at the character with offset `charOffset` in node, splitting - // the text node if needed to do so. Returns `null` if charOffset is at (or past) the end of node. - private _findAndSplitOffset(node: Node, charOffset: number): Node | null { - // Using nested function to make sure node is not returned by reference. - function process(n: Node): Node | null { - // TEXT_NODE = 3; - // CDATA_SECTION_NODE = 4; - if (n.nodeType === 3 || n.nodeType === 4) { - const text = n as Text; - if (charOffset <= 0) { - return text; - } - - const length = text.length; - if (charOffset < length) { - return text.splitText(charOffset); - } - - charOffset -= length; - } - - const children = n.childNodes; - const childCount = children.length; - - for (let i = 0; i < childCount; ++i) { - const tn = process(children[i]); - - if (tn != null) { - return tn; - } - } - - return null; - } - - return process(node); - } - - // Next node following the end or closing tag of the given node. - private _nextNodeNotChild(node: HTMLElement | Node): Node | null { - return node && (node.nextSibling ?? this._nextNodeNotChild(node.parentNode)); - } - - // Next node following the start of the given node, including child nodes. - private _nextNode(node: HTMLElement | Node): Node { - return node && (node.firstChild ?? node.nextSibling ?? this._nextNodeNotChild(node.parentNode)); - } - - // Previous node before the beginning or opening tag of the given node. - private _previousNodeNotChild(node: HTMLElement | Node): Node { - return node && (node.previousSibling ?? this._previousNodeNotChild(node.parentNode)); - } - - // Previous node before the beginning of the given node, including child nodes. - private _previousNode(node: HTMLElement | Node): Node { - return node && (node.lastChild ?? node.previousSibling ?? this._previousNodeNotChild(node.parentNode)); - } + /** + * Find current page information, including physical page, css selector of the current position, and the nearest ToC element id. + */ + public getPageInformation(): PageInformation { + const physicalPage = this.#findCurrentPhysicalPage(); + const cssSelector = this.#findCssSelector(); + const tocSelector = this.#findTocId(cssSelector); - // First non-whitespace character at or after the given node/charOffset. - private _findNonWhitespaceForward(node: HTMLElement | Node, charOffset: number) { - while (node != null) { - if (this._isTextNode(node)) { - const data = (node as Text).data; - charOffset = Math.max(charOffset, 0); - - while (charOffset < data.length) { - if (data[charOffset].trim() !== '') { - return { - node, - charOffset, - }; - } - - ++charOffset; - } - - charOffset = 0; - } - - node = this._nextNode(node); - } - } - - // First non-whitespace character at or before the given node/charOffset. - private _findNonWhitespaceBackward(node: HTMLElement | Node, charOffset?: number) { - while (node != null) { - if (this._isTextNode(node)) { - const data = (node as Text).data; - const last = data.length - 1; - charOffset = Math.min(charOffset ?? last, last); - - while (charOffset >= 0) { - if (data[charOffset].trim() !== '') { - return { - node, - charOffset, - }; - } - - --charOffset; - } - - charOffset = undefined; - } - - node = this._previousNode(node); - } - } - - // First non-whitespace character at or before the given node/charOffset. - private _findNonWhitespace(node: HTMLElement | Node, charOffset: number) { - return this._findNonWhitespaceBackward(node, charOffset) ?? this._findNonWhitespaceForward(node, charOffset); - } - - // Creates a new span specified by locator, with the style attribute given by style. Split into - // multiple spans if needed. - private _setLocation(locator: Locator, isAudioBookWithText: boolean) { - const currentLink = readium.link; - this._debugLog(`create:`, locator, currentLink); - - const locations = locator.locations; - const startCssSelector = locations.cssSelector ?? locations?.domRange?.start?.cssSelector; - - if (!startCssSelector) { - this._errorLog(`Start css selector not found`); - - return; - } - - const startParent = document.querySelector(startCssSelector); - if (!startParent) { - this._errorLog(`Start parent not found`); - - return; - } - - const endCssSelector = locations?.domRange?.end.cssSelector ?? startCssSelector; - const endParent = endCssSelector === startCssSelector ? startParent : document.querySelector(endCssSelector); - - if (!endParent) { - this._errorLog(`End parent not found`); - - return; - } - - const startOffset = locations?.domRange?.start?.charOffset; - const endOffset = locations?.domRange?.end?.charOffset; - - // highlight for audiobooks with text - if (!startOffset && !endOffset && isAudioBookWithText) { - this._wrapWithLocationElement(startParent); - - return; - } - - // Iterate over text nodes between startText and endText (if null, use end of parent). - const startNode = this._findAndSplitOffset(startParent, startOffset) ?? this._nextNodeNotChild(startParent); - const endNode = this._findAndSplitOffset(endParent, endOffset) ?? this._nextNodeNotChild(endParent); - - const texts = new Array(); - - for (let node = startNode; node && node !== endNode; node = this._nextNode(node)) { - if (this._isTextNode(node)) { - texts.push(node as Text); - } - } - - for (const text of texts) { - const locationEl = this._setLocationElement(); - locationEl.appendChild(text.cloneNode(true)); - text.replaceWith(locationEl); - } - } - - // Removes a previously-added span (but doesn't remove the contents of the span). - private _removeLocation() { - this._debugLog('Remove old location'); - - const nodes = document.querySelectorAll(`#${this._activeLocationId}`); - nodes?.forEach((node) => { - if (this._isAndroid()) { - // Ugly workaround for randomly changing layout on Android emulator. - // Leaks lots of useless s. - node.removeAttribute('id'); - return; - } - - const parent = node.parentNode; - node.replaceWith(...node.childNodes); - parent.normalize(); - }); - } - - // The screen rectangle in horizontal scrolling mode and a slightly shortened screen rectangle in - // vertical scrolling mode. - private _safeVisibleRect(isVerticalScroll: boolean): Rect { - const { innerWidth, innerHeight } = window; - - if (isVerticalScroll) { - return { - left: 0, - top: 0.05 * innerHeight, - right: innerWidth, - bottom: 0.95 * innerHeight, - }; - } return { - left: 0, - top: 0, - right: innerWidth, - bottom: innerHeight, + physicalPage, + cssSelector, + tocSelector, }; } - private *_descendentTextNodes(node: Node): Generator { - if (this._isTextNode(node)) { - yield node as Text; - } else { - for (const child of node.childNodes) { - yield* this._descendentTextNodes(child); - } - } - } - - private _findTextPosition(node: Node, charOffset: number) { - // Converts a text offset in a node into something suitable for Range.setStart or Range.setEnd. - if (node == null) { - this._errorLog(`findTextPosition: no node, charOffset=${charOffset}`); - return; - } - - if (charOffset < 0 || isNaN(charOffset)) { - this._errorLog(`findTextPosition: invalid charOffset, node=${node.nodeValue}, charOffset=${charOffset}`); - return; - } - - if (charOffset === 0) { - return { - node, - charOffset, - }; + /** + * Find the nearest cssSelector that is an id. + * + * @param cssSelector + * @returns cssSelector that is guaranteed to be an id, or null if no element can be found. + */ + #findCssSelector(): string | null { + const firstVisibleCssSelector = this.#findFirstVisibleCssSelector(); + const cssSelector = readium.findFirstVisibleLocator()?.locations?.cssSelector ?? null; + if (cssSelector == null) { + return null; } - for (const textNode of this._descendentTextNodes(node)) { - const length = textNode.length; - - if (charOffset <= length) { - return { - node: textNode, - charOffset, - }; + let selectorElement = document.querySelector(cssSelector) as HTMLElement; + if (selectorElement == null) { + if (firstVisibleCssSelector) { + return firstVisibleCssSelector; } - charOffset -= length; + return null; } - this._errorLog(`findTextPosition: failed, node=${this._debugNode(node)}, charOffset=${charOffset}`); - - return; - } - - private _processDomRange(domRange: DomRange) { - const { start, end } = domRange; - const { cssSelector: startSelector, charOffset: startOffset } = start; - const { cssSelector: endSelector, charOffset: endOffset } = end != null && end !== void 0 ? end : start; - const startNode = document.querySelector(startSelector); - const startBoundary = this._findTextPosition(startNode, startOffset ?? 0); - - if (startBoundary == null) { - this._errorLog(`DomRange bad start, selector=${startSelector}`); - return; + if (selectorElement.id) { + return `#${selectorElement.id}`; } - const endNode = endSelector === startSelector ? startNode : document.querySelector(endSelector); - const endBoundary = this._findTextPosition(endNode, endOffset != null && endOffset !== void 0 ? endOffset : 0); - - if (endBoundary == null) { - this._errorLog(`DomRange bad end, selector=${endSelector}`); - return; + if (firstVisibleCssSelector) { + return firstVisibleCssSelector; } - try { - this._documentRange.setStart(startBoundary.node, startBoundary.charOffset); - // this.debugLog(`range.setStart(${startBoundary.node.id ?? startBoundary.node.nodeName}, ${startBoundary.charOffset});`); - } catch (e) { - this._errorLog(`${this._debugNode(startBoundary.node)}, ${startBoundary.charOffset}`, e); - this._documentRange.setStartAfter(startBoundary.node); + // Some locators land inside injected spans + if (selectorElement.nodeType !== Node.ELEMENT_NODE) { + selectorElement = selectorElement.parentElement; } - try { - this._documentRange.setEnd(endBoundary.node, endBoundary.charOffset); - // this.debugLog(`range.setEnd(${endBoundary.node.id ?? endBoundary.node.nodeName}, ${endBoundary.charOffset});`); - } catch (e) { - this._errorLog(`${this._debugNode(endBoundary.node)}, ${endBoundary.charOffset}`, e); - this._documentRange.setEndAfter(endBoundary.node); - } + // 1. Closest ancestor with ID + const ancestor = selectorElement.closest('[id]'); + if (ancestor) { + return `#${ancestor.id}`; + }; - // Work around possible bad getClientBoundingRect data when the start/end of the range is the - // same. Browser bug? Seen on an Android device, not sure whether it happens on iOS. - // https://stackoverflow.com/questions/59767515/incorrect-positioning-of-getboundingclientrect-after-newline-character - if (this._documentRange.getClientRects().length === 0) { - const pos = this._findNonWhitespace(startBoundary.node, startBoundary.charOffset); + // 2. Nearest element with ID, either preceding or following the current element in the document order. + const precedingElementXPath = 'preceding::*[@id][1]'; + const followingElementXPath = 'following::*[@id][1]'; + + for (const xpath of [precedingElementXPath, followingElementXPath]) { + const result = document.evaluate( + xpath, + selectorElement, + null, + XPathResult.FIRST_ORDERED_NODE_TYPE, + null + ); - if (pos == null) { - this._errorLog(`Couldn't find any non-whitespace characters in the document!'`); - return; + if (result.singleNodeValue instanceof Element) { + return `#${result.singleNodeValue.id}`; } - - const { node, charOffset } = pos; - this._documentRange.setStart(node, charOffset); - this._documentRange.setEnd(node, charOffset + 1); } - - return this._documentRange; } - private _processCssSelector(cssSelector: string) { - const node = document.querySelector(cssSelector); + /** + * Find the nearest cssSelector that is visible, starting from the first visible element and ending at the given cssSelector. + */ + #findFirstVisibleCssSelector(): string | null { + const firstVisibleElement = this.#findFirstVisibleElement(); + const lastVisibleElement = this.#findLastVisibleElement(); - if (node == null) { - this._errorLog(`processCssSelector: error: node not found ${cssSelector}`); - return; - } - - // Make sure node is visible on the page in order to get the range. - if (window.getComputedStyle(node).display === 'none') { - (node as HTMLElement).style.display = this._isPageBreakElement(node) ? 'flex' : 'block'; + if (firstVisibleElement == null || lastVisibleElement == null) { + return null; } - this._documentRange.selectNode(node); - return this._documentRange; - } + const walker = document.createTreeWalker( + document.body, + NodeFilter.SHOW_ELEMENT + ); - private _processLocations(locations: Locations): Range | null { - if (locations == null) { - this._errorLog('location not set'); + walker.currentNode = firstVisibleElement; + let node: Node = firstVisibleElement; - return; - } + while (node) { + if (node instanceof Element && node.id) return `#${node.id}`; - if (locations.domRange) { - return this._processDomRange(locations.domRange); - } + if (node === lastVisibleElement) break; - const selector = locations.cssSelector ?? locations.domRange?.start?.cssSelector; - if (selector) { - return this._processCssSelector(selector); + node = walker.nextNode(); } } - private _scrollToProcessedRange(range: Range, isVerticalScroll: boolean, toStart: boolean) { - if (toStart || !this._isProcessedRangeVisible(range)) { - this._scrollToBoundingClientRect(range, isVerticalScroll); - } - } - - private _scrollToBoundingClientRect(range: Range, isVerticalScroll: boolean) { - const { top, right, bottom, left } = range.getBoundingClientRect(); - - if (top === 0 && right === 0 && bottom === 0 && left === 0) { - this._debugLog(`scrollToBoundingClientRect: Scrolling to defective bounding rect, abort! `, range.getClientRects(), range.getClientRects().length); - return; + /** + * Find the preceding Table of Contents element id. + * @param cssSelector + * @returns + */ + #findTocId(cssSelector: string | null): string | null { + if (this.#tocIds == null || this.#tocIds.length === 0 || cssSelector == null) { + return null; } - const { scrollLeft, scrollWidth, scrollTop, scrollHeight } = document.scrollingElement; - - if (isVerticalScroll) { - const { top: minHeight, bottom: maxHeight } = this._safeVisibleRect(isVerticalScroll); - - if (top < minHeight || bottom > maxHeight) { - const offset = this._clamp((scrollTop + top - minHeight) / scrollHeight, 0, 1); - readium?.scrollToPosition(offset); + // First check if any of the registered ToC ids are currently visible and return the first one found. + for (const tocId of this.#tocIds) { + const tocElement = document.getElementById(tocId); + if (this.#isElementVisible(tocElement)) { + return `#${tocId}`; } - } else { - const offset = (scrollLeft + 0.5 * (left + right)) / scrollWidth; - readium?.scrollToPosition(offset); } - } - - private _isProcessedRangeVisible(range: Range) { - const { innerWidth, innerHeight } = window; - const { top, right, bottom, left } = range.getBoundingClientRect(); - return top < innerHeight && 0 < bottom && left < innerWidth && 0 < right; - } - - private _getPageFragments(isVerticalScroll: boolean): string[] { - try { - const { scrollLeft, scrollWidth } = document.scrollingElement; - - const { innerWidth } = window; - const pageIndex = isVerticalScroll ? null : Math.round(scrollLeft / innerWidth) + 1; - const totalPages = isVerticalScroll ? null : Math.round(scrollWidth / innerWidth); - return [`page=${pageIndex}`, `totalPages=${totalPages}`]; - } catch (error) { - this._errorLog(error); - - return []; + // Then find the nearest ToC id to the current cssSelector, either preceding or following in the document order. + const selectorElement = document.querySelector(cssSelector); + if (selectorElement == null) { + return null; } - } - - private _getDurationFragment(fragments: string[]): number | null { - try { - const durationFragment = fragments.find((fragment) => fragment.includes('duration=')); - if (!durationFragment) { - this._errorLog('Duration fragment not found.'); - return; - } - - const durationMatch = /duration=(\d+(?:\.\d+)?)/.exec(durationFragment); - if (!durationMatch) { - this._errorLog('Invalid duration format.'); - return; - } - this._debugLog(`Duration fragment:`, durationMatch[1]); - return parseFloat(durationMatch[1]); - } catch (error) { - this._errorLog('Could not retrieve duration fragment!'); - return; + // If the current element itself is a ToC element, return it immediately. + if (selectorElement.id && this.#tocIds.includes(selectorElement.id.toLocaleLowerCase())) { + return `#${selectorElement.id}`; } - } - private _getTocFragments(selector: string): string[] { - try { - const headings = this._findPrecedingAncestorSiblingHeadings(selector); - const id = headings[0]?.id; - if (id == null) { - return []; - } + // Find the preceding ToC element. + const predicate = this.#tocIds.map((id) => `@id="${id}"`).join(" or "); - return [`toc=${id}`]; - } catch (error) { - this._errorLog(error); + const precedingElementXPath = `preceding::*[${predicate}][1]`; + const result = document.evaluate( + precedingElementXPath, + selectorElement, + null, + XPathResult.FIRST_ORDERED_NODE_TYPE, + null + ); - return []; + if (result.singleNodeValue instanceof Element) { + return `#${result.singleNodeValue.id}`; } - } - private _getPhysicalPageFragments(selector: string): string[] { - try { - const currentPhysicalPage = this._findCurrentPhysicalPage(selector); - - if (currentPhysicalPage == null) { - return []; + // This might be a special case, where we start just before the first ToC element. + let firstTocElement: Element; + for (const tocId of this.#tocIds) { + const tocElement = document.getElementById(tocId); + if (tocElement) { + firstTocElement = tocElement; + break; } - - return [`physicalPage=${currentPhysicalPage}`]; - } catch (error) { - this._errorLog(`Selector:${selector} -- ${error}`); - - return []; - } - } - - // TODO: Code below is from Thorium project. - // Use Intersection Observer API instead: - // https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API - private _findPrecedingAncestorSiblingHeadings(selector: string | null): ICurrentHeading[] | null { - const selectorElement = document.querySelector(selector); - - // Check if the element contains any heading before finding parent or sibling heading. - const currentElement = selectorElement?.querySelectorAll(this._headingTagNames.join(','))[0] ?? selectorElement; - - if (currentElement == null) { - return; } - if (!this._allHeadings) { - const headingElements = Array.from(window.document.querySelectorAll(this._headingTagNames.join(','))); - for (const hElement of headingElements) { - if (hElement) { - const el = hElement; - const t = el.textContent || el.getAttribute('title') || el.getAttribute('aria-label'); - let i = el.getAttribute('id'); - if (!i) { - // common authoring pattern: parent section (or other container element) has the navigation target anchor - let cur = el; - let p: Element | null; - while ((p = cur.parentNode as Element | null) && p?.nodeType === Node.ELEMENT_NODE) { - if (p.firstElementChild !== cur) { - break; - } - - const di = p.getAttribute('id'); - if (di) { - i = di; - break; - } - - cur = p; - } - } - const heading: IHeadingElement = { - element: el, - id: i ? i : null, - level: parseInt(el.localName.substring(1), 10), - text: t, - }; - if (!this._allHeadings) { - this._allHeadings = []; - } - this._allHeadings.push(heading); - } - } - - if (!this._allHeadings) { - this._allHeadings = []; - } + if (firstTocElement == null) { + // Really shouldn't happen. + return null; } - let arr: ICurrentHeading[] | null; - for (let i = this._allHeadings.length - 1; i >= 0; i--) { - const heading = this._allHeadings[i]; - - const c = currentElement.compareDocumentPosition(heading.element); + // Walk backwards from the first to see if the find the current selector element. + const walker = document.createTreeWalker( + document.body, + NodeFilter.SHOW_ELEMENT + ); - // eslint-disable-next-line no-bitwise - if (c === 0 || c & Node.DOCUMENT_POSITION_PRECEDING || c & Node.DOCUMENT_POSITION_CONTAINS) { - if (!arr) { - arr = []; - } + walker.currentNode = firstTocElement; + let node: Node = firstTocElement; - // Don't add the heading since the id is missing and it means that toc element does not - // points to this heading. Probably the element is wrapped in `body` or `section` element - // which will handled further below. - if (heading?.id) { - arr.push({ - id: heading.id, - level: heading.level, - text: heading.text, - }); - } + while (node) { + if (node instanceof Element && node === selectorElement) { + // First ToC element is the current one. + return `#${firstTocElement.id}`; } - } - - if (arr?.length) { - return arr; - } - // No heading found try with closes section or body - const closetSectionOrBody = selectorElement.closest('section') ?? selectorElement.closest('body'); - if (closetSectionOrBody) { - return [ - { - id: closetSectionOrBody.id, - level: 0, - text: closetSectionOrBody.innerText, - }, - ]; + node = walker.previousNode(); } } - private _isPageBreakElement(element: Element | null): boolean { + /** + * Is the given element a page break element, based on EPUB specification or common practices? + * @param element + * @returns + */ + #isPageBreakElement(element: Element | null): boolean { if (element == null) { return false; } - return element.getAttribute('type') === 'pagebreak'; + return element.getAttributeNS("http://www.idpf.org/2007/ops", "type") === 'pagebreak' || element.getAttribute('type') === 'pagebreak' || element.getAttribute('epub:type') === 'pagebreak'; } - private _getPhysicalPageIndexFromElement(element: HTMLElement): string | null { - return element?.getAttribute('title') ?? element?.innerText.trim(); - } - - private _findPhysicalPageIndex(element: Element | null): string | null { - if (element == null || !(element instanceof Element)) { - return null; - } else if (this._isPageBreakElement(element)) { - return this._getPhysicalPageIndexFromElement(element as HTMLElement); - } - - const pageBreakElement = element?.querySelector('.page-normal, .page-front, .page-special'); - - if (pageBreakElement == null) { + /** + * Get the physical page text from the given element, if it is a page break element. + * @param element + * @returns + */ + #getPhysicalPageText(element: HTMLElement): string | null { + if (!this.#isPageBreakElement(element)) { return null; } - return this._getPhysicalPageIndexFromElement(pageBreakElement as HTMLElement); - } - - private _getAllSiblings(elem: ChildNode): HTMLElement[] | null { - const sibs: HTMLElement[] = []; - elem = elem?.parentNode?.firstChild as HTMLElement; - do { - if (elem?.nodeType === 3) continue; // text node - sibs.push(elem as HTMLElement); - } while ((elem = elem?.nextSibling as HTMLElement)); - return sibs; + return element?.getAttribute('title') ?? element?.innerText.trim(); } - private _findCurrentPhysicalPage(cssSelector: string): string | null { - let element = document.querySelector(cssSelector); - - if (element == null) { + /** + * Find the current physical page index. + * + * @returns The physical page index, or null if it cannot be determined. + */ + #findCurrentPhysicalPage(): string | null { + let element = this.#findFirstVisibleElement(); + if (!(element instanceof HTMLElement)) { return; } - if (this._isPageBreakElement(element)) { - return this._getPhysicalPageIndexFromElement(element as HTMLElement); + if (this.#isPageBreakElement(element)) { + return this.#getPhysicalPageText(element); } - while (element.nodeType === Node.ELEMENT_NODE) { - const siblings = this._getAllSiblings(element); - if (siblings == null) { - return; - } - const currentIndex = siblings.findIndex((e) => e?.isEqualNode(element)); - - for (let i = currentIndex; i >= 0; i--) { - const e = siblings[i]; - - const pageBreakIndex = this._findPhysicalPageIndex(e); - - if (pageBreakIndex != null) { - return pageBreakIndex; + const result = document.evaluate( + 'preceding::*[@epub:type="pagebreak" or @type="pagebreak" or @role="doc-pagebreak" or contains(@class,"pagebreak")][1]', + element, + (prefix: string) => { + if (prefix === "epub") { + return "http://www.idpf.org/2007/ops"; } - } - - element = element.parentNode as HTMLElement; + return null; + }, + XPathResult.FIRST_ORDERED_NODE_TYPE, + null + ); - if (element == null || element.nodeName.toLowerCase() === 'body') { - return document.querySelector("head [name='webpub:currentPage']")?.getAttribute('content'); - } + if (result.singleNodeValue instanceof Element && this.#isPageBreakElement(result.singleNodeValue)) { + return this.#getPhysicalPageText(result.singleNodeValue as HTMLElement); } } - private _findFirstVisibleCssSelector(): string { - const selector = this._getCssSelector(this._getFirstVisibleElement()); - - return selector; - } - - private _getCssSelector(element: Element): string { - try { - const selector = getCssSelector(element, { - root: document.querySelector('body'), - }); - - // Sometimes getCssSelector returns `:root > :nth-child(2)` instead of `body` - // In such cases, replace it with `body` - const cssSelector = selector?.replace(':root > :nth-child(2)', 'body')?.trim() ?? 'body'; + /** + * Find the first visible element in the document. + * @returns The first visible element, or null if none is found. + */ + #findFirstVisibleElement(): Element | null { + const walker = document.createTreeWalker( + document.body, + NodeFilter.SHOW_ELEMENT + ); - this._debugLog(cssSelector); + walker.currentNode = document.body.firstElementChild ?? document.body; - return cssSelector; - } catch (error) { - this._errorLog(error); + let node = walker.currentNode; + while (node) { + if (node instanceof Element && this.#isElementVisible(node)) { + return node; + } - return 'body'; + node = walker.nextNode(); } } - private _getFirstVisibleElement(): Element { - const element = this._findFirstVisibleElement(document.body); - - this._debugLog(`First visible element:`, { - tagName: element.nodeName.toLocaleLowerCase(), - id: element.id, - className: element.className, - }); - - return element; - } - - private _findFirstVisibleElement(node: Element): Element { - const nodeData = { - tagName: node.nodeName.toLocaleLowerCase(), - id: node.id, - className: node.className, - }; - - for (const child of node.children) { - const childData = { - tagName: child.nodeName.toLocaleLowerCase(), - id: child.id, - className: child.className, - }; - - if (!this._isElementVisible(child)) { - // Uncomment only when debugging. - // this._debugLog(`Not visible - continue`, childData); - - continue; - } - - if (this._shouldIgnoreElement(child)) { - this._debugLog(`Element is ignored - continue`, childData); - - continue; - } - - if (child.id.includes(`${this._activeLocationId}`)) { - this._debugLog(`Child is an active location element, return closest element with id`, { childData, nodeData }); - - return node.id ? node : this._findClosestElementWithId(child); - } + /** + * Find the last visible element in the document. + * @returns The last visible element, or null if none is found. + */ + #findLastVisibleElement() { + const walker = document.createTreeWalker( + document.body, + NodeFilter.SHOW_ELEMENT + ); - if (child.hasChildNodes()) { - this._debugLog(`Loop into children`, childData); + walker.currentNode = document.body.lastElementChild ?? document.body; - return this._findFirstVisibleElement(child); + let node = walker.currentNode; + while (node) { + if (node instanceof Element && this.#isElementVisible(node)) { + return node; } - // This should not happens - if (!child.id) { - this._debugLog(`Element has no ID attribute - return closest element with id`, childData); - - return node.id ? node : this._findClosestElementWithId(child); - } + node = walker.previousNode(); } - - this._debugLog(`return:`, nodeData); - - return node; } - private _findClosestElementWithId(element: Element): Element | null { - let currentElement = element.parentElement; - - while (currentElement !== null) { - if (currentElement.id) { - return currentElement; - } - currentElement = currentElement.parentElement; + // Functions below was copied from Swift-toolkit - see License.readium-swift-toolkit for details. + #isElementVisible(element: Element | null): boolean { + if (this.#shouldIgnoreElement(element)) { + return false; } - this._debugLog('No element with id attr found!'); - return element; - } - - // Returns first visible element in viewport. - // True `fullVisibility` will ignore the element if it starts on previous pages. - private _isElementVisible(element: Element, fullVisibility = false): boolean { - if (readium?.isFixedLayout) { - return true; - } + if (readium.isFixedLayout) return true; if (element === document.body || element === document.documentElement) { return true; - } else if (!document || !document.documentElement || !document.body) { - return false; } - const rect = element.getBoundingClientRect(); - - if (fullVisibility) { - if (this._isScrollModeEnabled()) { - return rect.top >= 0 && rect.top <= document.documentElement.clientHeight; - } - - if (rect.left >= 1) { - return true; - } - + if (!document || !document.documentElement || !document.body) { return false; } - if (this._isScrollModeEnabled()) { + const rect = element.getBoundingClientRect(); + if (this.#isScrollModeEnabled) { return rect.bottom > 0 && rect.top < window.innerHeight; + } else { + return rect.right > 0 && rect.left < window.innerWidth; } - - return rect.right > 0 && rect.left < window.innerWidth; } - private _shouldIgnoreElement(element: Element): boolean { - const elStyle = window.getComputedStyle(element); + #shouldIgnoreElement(element: Element | null): boolean { + if (element == null) { + return true; + } + + const elStyle = getComputedStyle(element); if (elStyle) { - const display = elStyle.getPropertyValue('display'); - if (display === 'none') { + const display = elStyle.getPropertyValue("display"); + if (display != "block") { return true; } // Cannot be relied upon, because web browser engine reports invisible when out of view in @@ -948,113 +350,13 @@ export class EpubPage { // if (visibility === "hidden") { // return false; // } - const opacity = elStyle.getPropertyValue('opacity'); - if (opacity === '0') { + const opacity = elStyle.getPropertyValue("opacity"); + if (opacity === "0") { return true; } } - return this._isElementEmpty(element); - } - - private _isElementEmpty(element: Element): boolean { - const nodeName = element.tagName.toLowerCase(); - if (nodeName === 'img') { - return false; - } - - return element.textContent.trim() === ''; - } - - private _wrapWithLocationElement(el: Element): void { - const parentElement = el; - - if (parentElement) { - const locationEl = this._setLocationElement(); - - while (parentElement.firstChild) { - const child = parentElement.firstChild; - parentElement.removeChild(child); - locationEl.appendChild(child); - } - - parentElement.appendChild(locationEl); - } - } - - private _setLocationElement() { - const el = document.createElement(this._locationTag); - el.id = this._activeLocationId; - - return el; - } - - private _isScrollModeEnabled() { - const style = document.documentElement.style; - return ( - style.getPropertyValue('--USER__view').trim() === 'readium-scroll-on' || - // FIXME: Will need to be removed in Readium 3.0, --USER__scroll was incorrect. - style.getPropertyValue('--USER__scroll').trim() === 'readium-scroll-on' - ); - } - - private _debugLog(...args: unknown[]) { - this._log(`=======Flutter Readium Debug=====`); - this._log(args); - this._log(`=================================`); - } - - private _log(...args: unknown[]) { - // Alternative for webkit in order to print logs in flutter log outputs. - - if (this._isIos()) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-call - webkit?.messageHandlers.log.postMessage( - // eslint-disable-next-line @typescript-eslint/no-unsafe-call - [].slice - .call(args) - .map((x: unknown) => (x instanceof String ? `${x}` : `${JSON.stringify(x)}`)) - .join(', '), - ); - - return; - } - - // eslint-disable-next-line no-console - console.log(JSON.stringify(args)); - } - - private _debugNode(node: HTMLElement | Node | null): string | undefined { - if (node instanceof Node) { - const xmlSerializer = new XMLSerializer(); - return xmlSerializer.serializeToString(node); - } else if ('innerHTML' in node || 'textContent' in node) { - const element = node as HTMLElement; - return element.innerHTML ?? element.textContent ?? '?'; - } - } - - private _errorLog(...error: any) { - this._log(`v===v===v===v===v===v`); - this._log(`Error:`, error); - this._log(`Stack:`, error?.stack ?? new Error().stack.replace('\n', '->').replace('_errorLog', '')); - this._log(`^===^===^===^===^===^`); - } - - private _isIos(): boolean { - try { - return isIos; - } catch (error) { - return false; - } - } - - private _isAndroid(): boolean { - try { - return isAndroid; - } catch (error) { - return false; - } + return false; } } diff --git a/flutter_readium/assets/_helper_scripts/src/License.readium-swift-toolkit b/flutter_readium/assets/_helper_scripts/src/License.readium-swift-toolkit new file mode 100644 index 00000000..01c2b32d --- /dev/null +++ b/flutter_readium/assets/_helper_scripts/src/License.readium-swift-toolkit @@ -0,0 +1,31 @@ +Applies to Readium javascript functions. + +BSD 3-Clause License + +Copyright (c) 2017, Readium +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/flutter_readium/assets/_helper_scripts/src/types.ts b/flutter_readium/assets/_helper_scripts/src/types.ts index 670cc740..caca4bf3 100644 --- a/flutter_readium/assets/_helper_scripts/src/types.ts +++ b/flutter_readium/assets/_helper_scripts/src/types.ts @@ -25,24 +25,25 @@ export interface ComicFramePosition { * Readium JS library injected by kotlin/swift-toolkit. **/ export interface Readium { - link: any; - isFixedLayout: boolean; - isReflowable: boolean; + get isFixedLayout(): boolean | undefined; + get isReflowable(): boolean | undefined; /** * @param progression // Position must be in the range [0 - 1], 0-100%. */ scrollToPosition(progression: number): void; - getColumnCountPerScreen(): void; - - isScrollModeEnabled(): boolean; - - isVerticalWritingMode(): boolean; - - // Scroll to the given TagId in document and snap. + /** + * Scroll to the given TagId in document and snap. + */ scrollToId(id: string): void; + /** + * Scrolls to the first occurrence of the given text snippet. + * + * The expected text argument is a Locator object, as defined here: + * https://readium.org/architecture/models/locators/ + */ scrollToLocator(locator: Locator): void; scrollToStart(): void; @@ -51,16 +52,14 @@ export interface Readium { scrollLeft(): void; - snapCurrentOffset(): void; - - rangeFromLocator(): Range; + scrollRight(): void; setCSSProperties(properties: Record): void; setProperty(key: string, value: string): void; removeProperty(key: string): void; - + getCurrentSelection(): CurrentSelection; registerDecorationTemplates(newStyles: Record): void; @@ -132,4 +131,10 @@ export interface CurrentSelectionRect { export interface CurrentSelection { text: CurrentSelectionText; rect: CurrentSelectionRect; -} \ No newline at end of file +} + +export interface PageInformation { + physicalPage?: string | null; + cssSelector?: string | null; + tocSelector?: string | null; +} diff --git a/flutter_readium/assets/_helper_scripts/webpack.config.ts b/flutter_readium/assets/_helper_scripts/webpack.config.ts index 434e41c2..964e2867 100644 --- a/flutter_readium/assets/_helper_scripts/webpack.config.ts +++ b/flutter_readium/assets/_helper_scripts/webpack.config.ts @@ -22,13 +22,6 @@ export default function (): webpack.Configuration { clean: true, }, plugins: [ - new CopyPlugin({ - patterns: [ - { - from: 'public/.gitkeep', - }, - ], - }), new webpack.ProgressPlugin(), new MiniCssExtractPlugin({ filename: '[name].css' }), ], diff --git a/flutter_readium/assets/_helper_scripts/webpack.prod.ts b/flutter_readium/assets/_helper_scripts/webpack.prod.ts index 6b239007..4005fd0f 100644 --- a/flutter_readium/assets/_helper_scripts/webpack.prod.ts +++ b/flutter_readium/assets/_helper_scripts/webpack.prod.ts @@ -3,8 +3,10 @@ import * as webpack from 'webpack'; export default { mode: 'production', + // devtool: 'source-map', optimization: { minimizer: [new TerserPlugin()], + minimize: true, splitChunks: { cacheGroups: { diff --git a/flutter_readium/assets/helpers/.gitkeep b/flutter_readium/assets/helpers/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/flutter_readium/assets/helpers/epub.css b/flutter_readium/assets/helpers/epub.css index c71da7d4..43474c5a 100644 --- a/flutter_readium/assets/helpers/epub.css +++ b/flutter_readium/assets/helpers/epub.css @@ -1 +1 @@ -[type=pagebreak]{border-top:1px solid;display:block;width:100%;line-height:100%;padding-top:8px;margin-top:40px;margin-bottom:20px;text-align:right;font-size:98%}span#activeLocation{border-radius:4px;background-color:var(--USER__highlightBackgroundColor) !important;color:var(--USER__highlightForegroundColor) !important}body>*:first-child{margin-top:50px !important}*{word-wrap:break-word}table{border-collapse:collapse;border-spacing:0;margin:15px 0;width:calc(100% - var(--RS__pageGutter)/2);word-break:break-word}table h1,table h2,table h3,table h4,table h5,table h6{margin:0}table *{font-size:1rem}table td,table th{border-collapse:collapse;border:1px solid #ccc;margin:0;padding:16px;vertical-align:top}table caption{margin-bottom:16px}table.transparent-table,table.docx-table,table.plain-table{table-layout:fixed;width:100%}table.transparent-table th,table.transparent-table td,table.docx-table th,table.docx-table td,table.plain-table th,table.plain-table td{width:auto;max-width:100%}table.transparent-table{border-width:0}table.transparent-table td,table.transparent-table th{border-width:0}table.has-first-row-headers tr:first-child{display:none}table.has-header{border:none !important}table.has-header tr{display:block;margin-bottom:25px}table.has-header tr td{border-top-width:0;display:block;width:100% !important;box-sizing:border-box}table.has-header tr td:first-child{border-top-width:1px}table.has-header tr td:not(.mobile-header):before{background-color:#7c7c7c;color:#fff;content:attr(data-th);display:block;margin:-16px -16px 5px;padding:8px 16px}table.has-header tr td.mobile-header{text-transform:uppercase;background-color:#241f20 !important}table.has-header tr td.mobile-header h6{color:#fff}table.has-header thead tr:first-child{display:none} +[type=pagebreak]{border-top:1px solid;display:block;width:100%;line-height:100%;padding-top:8px;margin-top:40px;margin-bottom:20px;text-align:right;font-size:98%}body>*:first-child{margin-top:50px !important}*{word-wrap:break-word}table{border-collapse:collapse;border-spacing:0;margin:15px 0;width:calc(100% - var(--RS__pageGutter)/2);word-break:break-word}table h1,table h2,table h3,table h4,table h5,table h6{margin:0}table *{font-size:1rem}table td,table th{border-collapse:collapse;border:1px solid #ccc;margin:0;padding:16px;vertical-align:top}table caption{margin-bottom:16px}table.transparent-table,table.docx-table,table.plain-table{table-layout:fixed;width:100%}table.transparent-table th,table.transparent-table td,table.docx-table th,table.docx-table td,table.plain-table th,table.plain-table td{width:auto;max-width:100%}table.transparent-table{border-width:0}table.transparent-table td,table.transparent-table th{border-width:0}table.has-first-row-headers tr:first-child{display:none}table.has-header{border:none !important}table.has-header tr{display:block;margin-bottom:25px}table.has-header tr td{border-top-width:0;display:block;width:100% !important;box-sizing:border-box}table.has-header tr td:first-child{border-top-width:1px}table.has-header tr td:not(.mobile-header):before{background-color:#7c7c7c;color:#fff;content:attr(data-th);display:block;margin:-16px -16px 5px;padding:8px 16px}table.has-header tr td.mobile-header{text-transform:uppercase;background-color:#241f20 !important}table.has-header tr td.mobile-header h6{color:#fff}table.has-header thead tr:first-child{display:none} diff --git a/flutter_readium/assets/helpers/epub.js b/flutter_readium/assets/helpers/epub.js index fc8af5ff..1e91769c 100644 --- a/flutter_readium/assets/helpers/epub.js +++ b/flutter_readium/assets/helpers/epub.js @@ -1 +1 @@ -var epub;(()=>{var e={324(e,t,n){"use strict";n.r(t)},679(e,t){"use strict";Object.defineProperty(t,"__esModule",{value:!0}),t.initResponsiveTables=function(){let e=Array.from(document.querySelectorAll("table"));if(e=e.filter((e=>!e.classList.contains("docx-table")&&!e.classList.contains("transparent-table")&&!e.classList.contains("plain-table"))),!e||0===e.length)return;e.forEach((e=>{if(function(e){let t=0,n=0;if(e.querySelectorAll("tr").forEach((e=>{e.querySelectorAll("td").forEach((e=>{""===e.textContent.trim()?t++:n++}))})),t>n)return e.classList.add("plain-table"),!0;return!1}(e))return;const t=e.querySelectorAll("thead th").length,n=e.querySelectorAll("tbody tr");if(1===t&&Array.from(n).every((e=>2===e.querySelectorAll("td").length))){const t=n[0].querySelectorAll("td");if(Array.from(t).every((e=>e.querySelector("strong")||Array.from({length:6},((t,n)=>e.querySelector(`h${n+1}`))).some((e=>null!==e)))))return void e.classList.add("plain-table");!function(e){const t=e.querySelector("thead th").textContent,n=Array.from(e.querySelectorAll("tbody td"));e.innerHTML="";const o=document.createElement("tbody"),r=document.createElement("tr"),i=document.createElement("tr");r.innerHTML=" ",i.innerHTML=`${t}`,n.forEach(((e,t)=>{if(t%2==0){const t=document.createElement("td");t.innerHTML=`

${e.textContent}

`,r.appendChild(t)}else{const t=document.createElement("td");Array.from(e.childNodes).forEach((e=>t.appendChild(e.cloneNode(!0)))),i.appendChild(t)}})),o.appendChild(r),o.appendChild(i),e.appendChild(o)}(e)}!function(e){let t=null,n=[];if(e.tHead)t=e.tHead.rows[0],n=Array.from(t.cells);else{const o=e.rows[0];o&&o.querySelector("th, h1, h2, h3, h4, h5, h6, strong")&&(t=o,n=Array.from(o.cells),e.classList.add("has-first-row-headers"))}if(n.length){e.classList.add("has-header");const o=n.map((e=>{var t;let n=(null===(t=e.textContent)||void 0===t?void 0:t.trim())||"";return n.length&&(n+=":"),n}));Array.from(e.rows).filter((e=>e!==t)).forEach((e=>{if(Array.from(e.cells).forEach(((e,t)=>{e.setAttribute("data-th",o[t]||"")})),!e.cells[0].getAttribute("data-th")){e.cells[0].classList.add("mobile-header");const t=document.createElement("h6");t.textContent=e.cells[0].textContent,e.cells[0].textContent="",e.cells[0].appendChild(t)}}))}}(e)}))}},50(e){self,e.exports=(()=>{"use strict";var e={d:(t,n)=>{for(var o in n)e.o(n,o)&&!e.o(t,o)&&Object.defineProperty(t,o,{enumerable:!0,get:n[o]})},o:(e,t)=>Object.prototype.hasOwnProperty.call(e,t),r:e=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})}},t={};function n(e){return"object"==typeof e&&null!==e&&e.nodeType===Node.ELEMENT_NODE}e.r(t),e.d(t,{default:()=>K,getCssSelector:()=>X});const o={NONE:"",DESCENDANT:" ",CHILD:" > "},r={id:"id",class:"class",tag:"tag",attribute:"attribute",nthchild:"nthchild",nthoftype:"nthoftype"},i="CssSelectorGenerator";function l(e="unknown problem",...t){console.warn(`${i}: ${e}`,...t)}const s={selectors:[r.id,r.class,r.tag,r.attribute],includeTag:!1,whitelist:[],blacklist:[],combineWithinSelector:!0,combineBetweenSelectors:!0,root:null,maxCombinations:Number.POSITIVE_INFINITY,maxCandidates:Number.POSITIVE_INFINITY};function c(e){return e instanceof RegExp}function a(e){return["string","function"].includes(typeof e)||c(e)}function d(e){return Array.isArray(e)?e.filter(a):[]}function u(e){const t=[Node.DOCUMENT_NODE,Node.DOCUMENT_FRAGMENT_NODE,Node.ELEMENT_NODE];return function(e){return e instanceof Node}(e)&&t.includes(e.nodeType)}function h(e,t){if(u(e))return e.contains(t)||l("element root mismatch","Provided root does not contain the element. This will most likely result in producing a fallback selector using element's real root node. If you plan to use the selector using provided root (e.g. `root.querySelector`), it will not work as intended."),e;const n=t.getRootNode({composed:!1});return u(n)?(n!==document&&l("shadow root inferred","You did not provide a root and the element is a child of Shadow DOM. This will produce a selector using ShadowRoot as a root. If you plan to use the selector using document as a root (e.g. `document.querySelector`), it will not work as intended."),n):y(t)}function f(e){return"number"==typeof e?e:Number.POSITIVE_INFINITY}function g(e=[]){const[t=[],...n]=e;return 0===n.length?t:n.reduce(((e,t)=>e.filter((e=>t.includes(e)))),t)}function m(e){return[].concat(...e)}function v(e){const t=e.map((e=>{if(c(e))return t=>e.test(t);if("function"==typeof e)return t=>{const n=e(t);return"boolean"!=typeof n?(l("pattern matcher function invalid","Provided pattern matching function does not return boolean. It's result will be ignored.",e),!1):n};if("string"==typeof e){const t=new RegExp("^"+e.replace(/[|\\{}()[\]^$+?.]/g,"\\$&").replace(/\*/g,".+")+"$");return e=>t.test(e)}return l("pattern matcher invalid","Pattern matching only accepts strings, regular expressions and/or functions. This item is invalid and will be ignored.",e),()=>!1}));return e=>t.some((t=>t(e)))}function p(e,t,n){const o=Array.from(h(n,e[0]).querySelectorAll(t));return o.length===e.length&&e.every((e=>o.includes(e)))}function _(e,t){t=null!=t?t:y(e);const o=[];let r=e;for(;n(r)&&r!==t;)o.push(r),r=r.parentElement;return o}function b(e,t){return g(e.map((e=>_(e,t))))}function y(e){return e.ownerDocument.querySelector(":root")}const N=", ",S=new RegExp(["^$","\\s"].join("|")),E=new RegExp(["^$"].join("|")),L=[r.nthoftype,r.tag,r.id,r.class,r.attribute,r.nthchild],C=v(["class","id","ng-*"]);function T({name:e}){return`[${e}]`}function w({name:e,value:t}){return`[${e}='${t}']`}function x({nodeName:e,nodeValue:t}){return{name:H(e),value:H(null!=t?t:void 0)}}function P(e){const t=Array.from(e.attributes).filter((t=>function({nodeName:e,nodeValue:t},n){const o=n.tagName.toLowerCase();return!(["input","option"].includes(o)&&"value"===e||"src"===e&&(null==t?void 0:t.startsWith("data:"))||C(e))}(t,e))).map(x);return[...t.map(T),...t.map(w)]}function O(e){var t;return(null!==(t=e.getAttribute("class"))&&void 0!==t?t:"").trim().split(/\s+/).filter((e=>!E.test(e))).map((e=>`.${H(e)}`))}function A(e){var t;const n=null!==(t=e.getAttribute("id"))&&void 0!==t?t:"",o=`#${H(n)}`,r=e.getRootNode({composed:!1});return!S.test(n)&&p([e],o,r)?[o]:[]}function R(e){const t=e.parentNode;if(t){const o=Array.from(t.childNodes).filter(n).indexOf(e);if(o>-1)return[`:nth-child(${String(o+1)})`]}return[]}function I(e){return[H(e.tagName.toLowerCase())]}function $(e){const t=[...new Set(m(e.map(I)))];return 0===t.length||t.length>1?[]:[t[0]]}function k(e){const t=$([e])[0],n=e.parentElement;if(n){const o=Array.from(n.children).filter((e=>e.tagName.toLowerCase()===t)),r=o.indexOf(e);if(r>-1)return[`${t}:nth-of-type(${String(r+1)})`]}return[]}function M(e=[],{maxResults:t=Number.POSITIVE_INFINITY}={}){return Array.from(function*(e=[],{maxResults:t=Number.POSITIVE_INFINITY}={}){let n=0,o=F(1);for(;o.length<=e.length&&ne[t]));yield t,o=D(o,e.length-1)}}(e,{maxResults:t}))}function D(e=[],t=0){const n=e.length;if(0===n)return[];const o=[...e];o[n-1]+=1;for(let e=n-1;e>=0;e--)if(o[e]>t){if(0===e)return F(n+1);o[e-1]++,o[e]=o[e-1]+1}return o[n-1]>t?F(n+1):o}function F(e=1){return Array.from(Array(e).keys())}const q=":".charCodeAt(0).toString(16).toUpperCase(),j=/[ !"#$%&'()\[\]{|}<>*+,./;=?@^`~\\]/;function H(e=""){return CSS?CSS.escape(e):function(e=""){return e.split("").map((e=>":"===e?`\\${q} `:j.test(e)?`\\${e}`:escape(e).replace(/%/g,"\\"))).join("")}(e)}const V={tag:$,id:function(e){return 0===e.length||e.length>1?[]:A(e[0])},class:function(e){return g(e.map(O))},attribute:function(e){return g(e.map(P))},nthchild:function(e){return g(e.map(R))},nthoftype:function(e){return g(e.map(k))}},W={tag:I,id:A,class:O,attribute:P,nthchild:R,nthoftype:k};function B(e){return e.includes(r.tag)||e.includes(r.nthoftype)?[...e]:[...e,r.tag]}function U(e={}){const t=[...L];return e[r.tag]&&e[r.nthoftype]&&t.splice(t.indexOf(r.tag),1),t.map((t=>{return(o=e)[n=t]?o[n].join(""):"";var n,o})).join("")}function Y(e,t,n="",r){const i=function(e,t){return""===t?e:function(e,t){return[...e.map((e=>t+o.DESCENDANT+e)),...e.map((e=>t+o.CHILD+e))]}(e,t)}(function(e,t,n){const o=function(e,t){const{blacklist:n,whitelist:o,combineWithinSelector:r,maxCombinations:i}=t,l=v(n),s=v(o);return function(e){const{selectors:t,includeTag:n}=e,o=[...t];return n&&!o.includes("tag")&&o.push("tag"),o}(t).reduce(((t,n)=>{const o=function(e,t){return(0,V[t])(e)}(e,n),c=function(e=[],t,n){return e.filter((e=>n(e)||!t(e)))}(o,l,s),a=function(e=[],t){return e.sort(((e,n)=>{const o=t(e),r=t(n);return o&&!r?-1:!o&&r?1:0}))}(c,s);return t[n]=r?M(a,{maxResults:i}):a.map((e=>[e])),t}),{})}(e,n),r=function(e,t){return function(e){const{selectors:t,combineBetweenSelectors:n,includeTag:o,maxCandidates:r}=e,i=n?M(t,{maxResults:r}):t.map((e=>[e]));return o?i.map(B):i}(t).map((t=>function(e,t){const n={};return e.forEach((e=>{const o=t[e];o&&o.length>0&&(n[e]=o)})),function(e={}){let t=[];return Object.entries(e).forEach((([e,n])=>{t=n.flatMap((n=>0===t.length?[{[e]:n}]:t.map((t=>Object.assign(Object.assign({},t),{[e]:n})))))})),t}(n).map(U)}(t,e))).filter((e=>e.length>0))}(o,n),i=m(r);return[...new Set(i)]}(e,0,r),n);for(const n of i)if(p(e,n,t))return n;return null}function G(e){return{value:e,include:!1}}function z({selectors:e,operator:t}){let n=[...L];e[r.tag]&&e[r.nthoftype]&&(n=n.filter((e=>e!==r.tag)));let o="";return n.forEach((t=>{var n;(null!==(n=e[t])&&void 0!==n?n:[]).forEach((({value:e,include:t})=>{t&&(o+=e)}))})),t+o}function J(e){return[":root",..._(e).reverse().map((e=>{const t=function(e,t,n=o.NONE){const r={};return t.forEach((t=>{Reflect.set(r,t,function(e,t){return W[t](e)}(e,t).map(G))})),{element:e,operator:n,selectors:r}}(e,[r.nthchild],o.CHILD);return t.selectors.nthchild.forEach((e=>{e.include=!0})),t})).map(z)].join("")}function X(e,t={}){var o;const i=function(e){(e instanceof NodeList||e instanceof HTMLCollection)&&(e=Array.from(e));const t=(Array.isArray(e)?e:[e]).filter(n);return[...new Set(t)]}(e),l=function(e,t={}){const n=Object.assign(Object.assign({},s),t);return{selectors:(o=n.selectors,Array.isArray(o)?o.filter((e=>{return t=r,n=e,Object.values(t).includes(n);var t,n})):[]),whitelist:d(n.whitelist),blacklist:d(n.blacklist),root:h(n.root,e),combineWithinSelector:!!n.combineWithinSelector,combineBetweenSelectors:!!n.combineBetweenSelectors,includeTag:!!n.includeTag,maxCombinations:f(n.maxCombinations),maxCandidates:f(n.maxCandidates)};var o}(i[0],t),c=null!==(o=l.root)&&void 0!==o?o:y(i[0]);let a="",u=c;function g(){return function(e,t,n="",o){if(0===e.length)return null;const r=[e.length>1?e:[],...b(e,t).map((e=>[e]))];for(const e of r){const r=Y(e,t,n,o);if(r)return{foundElements:e,selector:r}}return null}(i,u,a,l)}let m=g();for(;m;){const{foundElements:e,selector:t}=m;if(p(i,t,c))return t;u=e[0],a=t,m=g()}return i.length>1?i.map((e=>X(e,l))).join(N):function(e){return e.map(J).join(N)}(i)}const K=X;return t})()}},t={};function n(o){var r=t[o];if(void 0!==r)return r.exports;var i=t[o]={exports:{}};return e[o](i,i.exports,n),i.exports}n.r=e=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})};var o={};(()=>{"use strict";var e=o;Object.defineProperty(e,"__esModule",{value:!0}),e.EpubPage=void 0;const t=n(50),r=n(679);n(324);class i{constructor(){this._headingTagNames=["h1","h2","h3","h4","h5","h6"],this._activeLocationId="activeLocation",this._locationTag="span",this._documentRange=document.createRange(),document.documentElement.style.setProperty("-webkit-line-box-contain","block inline replaced")}isReaderReady(){return!!readium}setLocation(e,t){var n,o,r,i,l,s,c,a,d,u;this._debugLog(e);try{if(null==e)return void this._debugLog("No locator set");if(this._removeLocation(),this._setLocation(e,t),this._isComicBook()){const t=null!==(s=null!==(o=null===(n=null==e?void 0:e.locations)||void 0===n?void 0:n.cssSelector)&&void 0!==o?o:null===(l=null===(i=null===(r=null==e?void 0:e.locations)||void 0===r?void 0:r.domRange)||void 0===i?void 0:i.start)||void 0===l?void 0:l.cssSelector)&&void 0!==s?s:null===(d=null===(a=null===(c=null==e?void 0:e.locations)||void 0===c?void 0:c.domRange)||void 0===a?void 0:a.end)||void 0===d?void 0:d.cssSelector;if(null==t)return void this._errorLog("Css selector not set!");const h=this._getDurationFragment(null===(u=null==e?void 0:e.locations)||void 0===u?void 0:u.fragments);if(null==h)return void this._errorLog("Duration not set!");window.GotoComicFrame(t,1e3*h)}}catch(e){this._errorLog(e)}}scrollToLocations(e,t,n){try{const o=this._processLocations(e);if(null!=o)return this._scrollToProcessedRange(o,t,n),!0;const r=e.progression;if(null!=r)return null===readium||void 0===readium||readium.scrollToPosition(r),!0;this._debugLog("ScrollToLocations: Unknown range",e)}catch(e){this._errorLog(e)}return!1}isLocatorVisible(e){var t,n,o;this._debugLog(e);try{const r=e.locations,i=null!==(t=r.cssSelector)&&void 0!==t?t:null===(o=null===(n=r.domRange)||void 0===n?void 0:n.start)||void 0===o?void 0:o.cssSelector;if(this._isComicBook()){const e=null!=document.querySelector(i);return this._debugLog("Comic book",r,{found:e,selector:i}),e}const l=this._processLocations(r);return null==l?(this._debugLog("isLocatorVisible: Unknown range",r),!1):this._isProcessedRangeVisible(l)&&!!document.querySelector(`${i} #${this._activeLocationId}`)}catch(e){return this._errorLog(e),!0}}getLocatorFragments(e,t){var n,o,r,i;try{const l=null!==(o=null===(n=null==e?void 0:e.locations)||void 0===n?void 0:n.cssSelector)&&void 0!==o?o:this._findFirstVisibleCssSelector();if(null==l||!(null==l?void 0:l.length))return this._debugLog("getLocatorFragments: selector not found, returning locator from args"),e;const s=[...this._getPageFragments(t),...this._getTocFragments(l),...this._getPhysicalPageFragments(l)];return Object.assign(Object.assign({},e),{locations:Object.assign(Object.assign({cssSelector:l},e.locations),{fragments:[...null!==(i=null===(r=e.locations)||void 0===r?void 0:r.fragments)&&void 0!==i?i:[],...s]})})}catch(t){return this._errorLog(t),e}}_isComicBook(){try{return!!(null===comicBookPage||void 0===comicBookPage?void 0:comicBookPage.isComicBook())}catch(e){return!1}}_isTextNode(e){const t=e.nodeType;return 3===t||4===t}_clamp(e,t,n){return Math.min(Math.max(e,t),n)}_findAndSplitOffset(e,t){return function e(n){if(3===n.nodeType||4===n.nodeType){const e=n;if(t<=0)return e;const o=e.length;if(t=0;){if(""!==n[t].trim())return{node:e,charOffset:t};--t}t=void 0}e=this._previousNode(e)}}_findNonWhitespace(e,t){var n;return null!==(n=this._findNonWhitespaceBackward(e,t))&&void 0!==n?n:this._findNonWhitespaceForward(e,t)}_setLocation(e,t){var n,o,r,i,l,s,c,a,d,u,h;const f=readium.link;this._debugLog("create:",e,f);const g=e.locations,m=null!==(n=g.cssSelector)&&void 0!==n?n:null===(r=null===(o=null==g?void 0:g.domRange)||void 0===o?void 0:o.start)||void 0===r?void 0:r.cssSelector;if(!m)return void this._errorLog("Start css selector not found");const v=document.querySelector(m);if(!v)return void this._errorLog("Start parent not found");const p=null!==(l=null===(i=null==g?void 0:g.domRange)||void 0===i?void 0:i.end.cssSelector)&&void 0!==l?l:m,_=p===m?v:document.querySelector(p);if(!_)return void this._errorLog("End parent not found");const b=null===(c=null===(s=null==g?void 0:g.domRange)||void 0===s?void 0:s.start)||void 0===c?void 0:c.charOffset,y=null===(d=null===(a=null==g?void 0:g.domRange)||void 0===a?void 0:a.end)||void 0===d?void 0:d.charOffset;if(!b&&!y&&t)return void this._wrapWithLocationElement(v);const N=null!==(u=this._findAndSplitOffset(v,b))&&void 0!==u?u:this._nextNodeNotChild(v),S=null!==(h=this._findAndSplitOffset(_,y))&&void 0!==h?h:this._nextNodeNotChild(_),E=new Array;for(let e=N;e&&e!==S;e=this._nextNode(e))this._isTextNode(e)&&E.push(e);for(const e of E){const t=this._setLocationElement();t.appendChild(e.cloneNode(!0)),e.replaceWith(t)}}_removeLocation(){this._debugLog("Remove old location");const e=document.querySelectorAll(`#${this._activeLocationId}`);null==e||e.forEach((e=>{if(this._isAndroid())return void e.removeAttribute("id");const t=e.parentNode;e.replaceWith(...e.childNodes),t.normalize()}))}_safeVisibleRect(e){const{innerWidth:t,innerHeight:n}=window;return e?{left:0,top:.05*n,right:t,bottom:.95*n}:{left:0,top:0,right:t,bottom:n}}*_descendentTextNodes(e){if(this._isTextNode(e))yield e;else for(const t of e.childNodes)yield*this._descendentTextNodes(t)}_findTextPosition(e,t){if(null!=e)if(t<0||isNaN(t))this._errorLog(`findTextPosition: invalid charOffset, node=${e.nodeValue}, charOffset=${t}`);else{if(0===t)return{node:e,charOffset:t};for(const n of this._descendentTextNodes(e)){const e=n.length;if(t<=e)return{node:n,charOffset:t};t-=e}this._errorLog(`findTextPosition: failed, node=${this._debugNode(e)}, charOffset=${t}`)}else this._errorLog(`findTextPosition: no node, charOffset=${t}`)}_processDomRange(e){const{start:t,end:n}=e,{cssSelector:o,charOffset:r}=t,{cssSelector:i,charOffset:l}=null!=n&&void 0!==n?n:t,s=document.querySelector(o),c=this._findTextPosition(s,null!=r?r:0);if(null==c)return void this._errorLog(`DomRange bad start, selector=${o}`);const a=i===o?s:document.querySelector(i),d=this._findTextPosition(a,null!=l&&void 0!==l?l:0);if(null!=d){try{this._documentRange.setStart(c.node,c.charOffset)}catch(e){this._errorLog(`${this._debugNode(c.node)}, ${c.charOffset}`,e),this._documentRange.setStartAfter(c.node)}try{this._documentRange.setEnd(d.node,d.charOffset)}catch(e){this._errorLog(`${this._debugNode(d.node)}, ${d.charOffset}`,e),this._documentRange.setEndAfter(d.node)}if(0===this._documentRange.getClientRects().length){const e=this._findNonWhitespace(c.node,c.charOffset);if(null==e)return void this._errorLog("Couldn't find any non-whitespace characters in the document!'");const{node:t,charOffset:n}=e;this._documentRange.setStart(t,n),this._documentRange.setEnd(t,n+1)}return this._documentRange}this._errorLog(`DomRange bad end, selector=${i}`)}_processCssSelector(e){const t=document.querySelector(e);if(null!=t)return"none"===window.getComputedStyle(t).display&&(t.style.display=this._isPageBreakElement(t)?"flex":"block"),this._documentRange.selectNode(t),this._documentRange;this._errorLog(`processCssSelector: error: node not found ${e}`)}_processLocations(e){var t,n,o;if(null==e)return void this._errorLog("location not set");if(e.domRange)return this._processDomRange(e.domRange);const r=null!==(t=e.cssSelector)&&void 0!==t?t:null===(o=null===(n=e.domRange)||void 0===n?void 0:n.start)||void 0===o?void 0:o.cssSelector;return r?this._processCssSelector(r):void 0}_scrollToProcessedRange(e,t,n){!n&&this._isProcessedRangeVisible(e)||this._scrollToBoundingClientRect(e,t)}_scrollToBoundingClientRect(e,t){const{top:n,right:o,bottom:r,left:i}=e.getBoundingClientRect();if(0===n&&0===o&&0===r&&0===i)return void this._debugLog("scrollToBoundingClientRect: Scrolling to defective bounding rect, abort! ",e.getClientRects(),e.getClientRects().length);const{scrollLeft:l,scrollWidth:s,scrollTop:c,scrollHeight:a}=document.scrollingElement;if(t){const{top:e,bottom:o}=this._safeVisibleRect(t);if(no){const t=this._clamp((c+n-e)/a,0,1);null===readium||void 0===readium||readium.scrollToPosition(t)}}else{const e=(l+.5*(i+o))/s;null===readium||void 0===readium||readium.scrollToPosition(e)}}_isProcessedRangeVisible(e){const{innerWidth:t,innerHeight:n}=window,{top:o,right:r,bottom:i,left:l}=e.getBoundingClientRect();return oe.includes("duration=")));if(!t)return void this._errorLog("Duration fragment not found.");const n=/duration=(\d+(?:\.\d+)?)/.exec(t);return n?(this._debugLog("Duration fragment:",n[1]),parseFloat(n[1])):void this._errorLog("Invalid duration format.")}catch(e){return void this._errorLog("Could not retrieve duration fragment!")}}_getTocFragments(e){var t;try{const n=null===(t=this._findPrecedingAncestorSiblingHeadings(e)[0])||void 0===t?void 0:t.id;return null==n?[]:[`toc=${n}`]}catch(e){return this._errorLog(e),[]}}_getPhysicalPageFragments(e){try{const t=this._findCurrentPhysicalPage(e);return null==t?[]:[`physicalPage=${t}`]}catch(t){return this._errorLog(`Selector:${e} -- ${t}`),[]}}_findPrecedingAncestorSiblingHeadings(e){var t,n;const o=document.querySelector(e),r=null!==(t=null==o?void 0:o.querySelectorAll(this._headingTagNames.join(","))[0])&&void 0!==t?t:o;if(null==r)return;if(!this._allHeadings){const e=Array.from(window.document.querySelectorAll(this._headingTagNames.join(",")));for(const t of e)if(t){const e=t,n=e.textContent||e.getAttribute("title")||e.getAttribute("aria-label");let o=e.getAttribute("id");if(!o){let t,n=e;for(;(t=n.parentNode)&&(null==t?void 0:t.nodeType)===Node.ELEMENT_NODE&&t.firstElementChild===n;){const e=t.getAttribute("id");if(e){o=e;break}n=t}}const r={element:e,id:o||null,level:parseInt(e.localName.substring(1),10),text:n};this._allHeadings||(this._allHeadings=[]),this._allHeadings.push(r)}this._allHeadings||(this._allHeadings=[])}let i;for(let e=this._allHeadings.length-1;e>=0;e--){const t=this._allHeadings[e],n=r.compareDocumentPosition(t.element);(0===n||n&Node.DOCUMENT_POSITION_PRECEDING||n&Node.DOCUMENT_POSITION_CONTAINS)&&(i||(i=[]),(null==t?void 0:t.id)&&i.push({id:t.id,level:t.level,text:t.text}))}if(null==i?void 0:i.length)return i;const l=null!==(n=o.closest("section"))&&void 0!==n?n:o.closest("body");return l?[{id:l.id,level:0,text:l.innerText}]:void 0}_isPageBreakElement(e){return null!=e&&"pagebreak"===e.getAttribute("type")}_getPhysicalPageIndexFromElement(e){var t;return null!==(t=null==e?void 0:e.getAttribute("title"))&&void 0!==t?t:null==e?void 0:e.innerText.trim()}_findPhysicalPageIndex(e){if(!(null!=e&&e instanceof Element))return null;if(this._isPageBreakElement(e))return this._getPhysicalPageIndexFromElement(e);const t=null==e?void 0:e.querySelector(".page-normal, .page-front, .page-special");return null==t?null:this._getPhysicalPageIndexFromElement(t)}_getAllSiblings(e){var t;const n=[];e=null===(t=null==e?void 0:e.parentNode)||void 0===t?void 0:t.firstChild;do{3!==(null==e?void 0:e.nodeType)&&n.push(e)}while(e=null==e?void 0:e.nextSibling);return n}_findCurrentPhysicalPage(e){var t;let n=document.querySelector(e);if(null!=n){if(this._isPageBreakElement(n))return this._getPhysicalPageIndexFromElement(n);for(;n.nodeType===Node.ELEMENT_NODE;){const e=this._getAllSiblings(n);if(null==e)return;for(let t=e.findIndex((e=>null==e?void 0:e.isEqualNode(n)));t>=0;t--){const n=e[t],o=this._findPhysicalPageIndex(n);if(null!=o)return o}if(n=n.parentNode,null==n||"body"===n.nodeName.toLowerCase())return null===(t=document.querySelector("head [name='webpub:currentPage']"))||void 0===t?void 0:t.getAttribute("content")}}}_findFirstVisibleCssSelector(){return this._getCssSelector(this._getFirstVisibleElement())}_getCssSelector(e){var n,o;try{const r=(0,t.getCssSelector)(e,{root:document.querySelector("body")}),i=null!==(o=null===(n=null==r?void 0:r.replace(":root > :nth-child(2)","body"))||void 0===n?void 0:n.trim())&&void 0!==o?o:"body";return this._debugLog(i),i}catch(e){return this._errorLog(e),"body"}}_getFirstVisibleElement(){const e=this._findFirstVisibleElement(document.body);return this._debugLog("First visible element:",{tagName:e.nodeName.toLocaleLowerCase(),id:e.id,className:e.className}),e}_findFirstVisibleElement(e){const t={tagName:e.nodeName.toLocaleLowerCase(),id:e.id,className:e.className};for(const n of e.children){const o={tagName:n.nodeName.toLocaleLowerCase(),id:n.id,className:n.className};if(this._isElementVisible(n))if(this._shouldIgnoreElement(n))this._debugLog("Element is ignored - continue",o);else{if(n.id.includes(`${this._activeLocationId}`))return this._debugLog("Child is an active location element, return closest element with id",{childData:o,nodeData:t}),e.id?e:this._findClosestElementWithId(n);if(n.hasChildNodes())return this._debugLog("Loop into children",o),this._findFirstVisibleElement(n);if(!n.id)return this._debugLog("Element has no ID attribute - return closest element with id",o),e.id?e:this._findClosestElementWithId(n)}}return this._debugLog("return:",t),e}_findClosestElementWithId(e){let t=e.parentElement;for(;null!==t;){if(t.id)return t;t=t.parentElement}return this._debugLog("No element with id attr found!"),e}_isElementVisible(e,t=!1){if(null===readium||void 0===readium?void 0:readium.isFixedLayout)return!0;if(e===document.body||e===document.documentElement)return!0;if(!document||!document.documentElement||!document.body)return!1;const n=e.getBoundingClientRect();return t?this._isScrollModeEnabled()?n.top>=0&&n.top<=document.documentElement.clientHeight:n.left>=1:this._isScrollModeEnabled()?n.bottom>0&&n.top0&&n.lefte instanceof String?`${e}`:`${JSON.stringify(e)}`)).join(", ")):console.log(JSON.stringify(e))}_debugNode(e){var t,n;if(e instanceof Node){return(new XMLSerializer).serializeToString(e)}if("innerHTML"in e||"textContent"in e){const o=e;return null!==(n=null!==(t=o.innerHTML)&&void 0!==t?t:o.textContent)&&void 0!==n?n:"?"}}_errorLog(...e){var t;this._log("v===v===v===v===v===v"),this._log("Error:",e),this._log("Stack:",null!==(t=null==e?void 0:e.stack)&&void 0!==t?t:(new Error).stack.replace("\n","->").replace("_errorLog","")),this._log("^===^===^===^===^===^")}_isIos(){try{return isIos}catch(e){return!1}}_isAndroid(){try{return isAndroid}catch(e){return!1}}}function l(){window.epubPage||((0,r.initResponsiveTables)(),document.removeEventListener("DOMContentLoaded",l),window.epubPage=new i)}e.EpubPage=i,"loading"!==document.readyState?window.setTimeout(l):document.addEventListener("DOMContentLoaded",l)})(),epub=o})(); \ No newline at end of file +var epub;(()=>{"use strict";var e={324(e,t,n){n.r(t)},295(e,t,n){var r,o,l,i,a,d,c,u,s,f,m,h,p,y=this&&this.__classPrivateFieldGet||function(e,t,n,r){if("a"===n&&!r)throw new TypeError("Private accessor was defined without a getter");if("function"==typeof t?e!==t||!r:!t.has(e))throw new TypeError("Cannot read private member from an object whose class did not declare it");return"m"===n?r:"a"===n?r.call(e):r?r.value:t.get(e)},E=this&&this.__classPrivateFieldSet||function(e,t,n,r,o){if("m"===r)throw new TypeError("Private method is not writable");if("a"===r&&!o)throw new TypeError("Private accessor was defined without a setter");if("function"==typeof t?e!==t||!o:!t.has(e))throw new TypeError("Cannot write private member to an object whose class did not declare it");return"a"===r?o.call(e,n):o?o.value=n:t.set(e,n),n};Object.defineProperty(t,"__esModule",{value:!0}),t.EpubPage=void 0;const b=n(679);n(324);class g{constructor(){r.add(this),l.set(this,[])}registerToc(e){E(this,l,y(this,l,"f").concat(e.map((e=>{var t,n;return null===(n=null===(t=document.getElementById(e))||void 0===t?void 0:t.id)||void 0===n?void 0:n.toLocaleLowerCase()})).filter((e=>null!=e))),"f"),console.error(`Registered ToC ids: ${y(this,l,"f").join(", ")}`)}getPageInformation(){const e=y(this,r,"m",s).call(this),t=y(this,r,"m",i).call(this);return{physicalPage:e,cssSelector:t,tocSelector:y(this,r,"m",d).call(this,t)}}}function v(){window.epubPage||((0,b.initResponsiveTables)(),document.removeEventListener("DOMContentLoaded",v),window.epubPage=new g)}t.EpubPage=g,l=new WeakMap,r=new WeakSet,o=function(){var e;return!0===readium.isReflowable&&'readium-scroll-on"'===(null===(e=getComputedStyle(document.documentElement).getPropertyValue("--USER__view"))||void 0===e?void 0:e.trim())},i=function(){var e,t,n;const o=y(this,r,"m",a).call(this),l=null!==(n=null===(t=null===(e=readium.findFirstVisibleLocator())||void 0===e?void 0:e.locations)||void 0===t?void 0:t.cssSelector)&&void 0!==n?n:null;if(null==l)return null;let i=document.querySelector(l);if(null==i)return o||null;if(i.id)return`#${i.id}`;if(o)return o;i.nodeType!==Node.ELEMENT_NODE&&(i=i.parentElement);const d=i.closest("[id]");if(d)return`#${d.id}`;for(const e of["preceding::*[@id][1]","following::*[@id][1]"]){const t=document.evaluate(e,i,null,XPathResult.FIRST_ORDERED_NODE_TYPE,null);if(t.singleNodeValue instanceof Element)return`#${t.singleNodeValue.id}`}},a=function(){const e=y(this,r,"m",f).call(this),t=y(this,r,"m",m).call(this);if(null==e||null==t)return null;const n=document.createTreeWalker(document.body,NodeFilter.SHOW_ELEMENT);n.currentNode=e;let o=e;for(;o;){if(o instanceof Element&&o.id)return`#${o.id}`;if(o===t)break;o=n.nextNode()}},d=function(e){if(null==y(this,l,"f")||0===y(this,l,"f").length||null==e)return null;for(const e of y(this,l,"f")){const t=document.getElementById(e);if(y(this,r,"m",h).call(this,t))return`#${e}`}const t=document.querySelector(e);if(null==t)return null;if(t.id&&y(this,l,"f").includes(t.id.toLocaleLowerCase()))return`#${t.id}`;const n=`preceding::*[${y(this,l,"f").map((e=>`@id="${e}"`)).join(" or ")}][1]`,o=document.evaluate(n,t,null,XPathResult.FIRST_ORDERED_NODE_TYPE,null);if(o.singleNodeValue instanceof Element)return`#${o.singleNodeValue.id}`;let i;for(const e of y(this,l,"f")){const t=document.getElementById(e);if(t){i=t;break}}if(null==i)return null;const a=document.createTreeWalker(document.body,NodeFilter.SHOW_ELEMENT);a.currentNode=i;let d=i;for(;d;){if(d instanceof Element&&d===t)return`#${i.id}`;d=a.previousNode()}},c=function(e){return null!=e&&("pagebreak"===e.getAttributeNS("http://www.idpf.org/2007/ops","type")||"pagebreak"===e.getAttribute("type")||"pagebreak"===e.getAttribute("epub:type"))},u=function(e){var t;return y(this,r,"m",c).call(this,e)?null!==(t=null==e?void 0:e.getAttribute("title"))&&void 0!==t?t:null==e?void 0:e.innerText.trim():null},s=function(){let e=y(this,r,"m",f).call(this);if(!(e instanceof HTMLElement))return;if(y(this,r,"m",c).call(this,e))return y(this,r,"m",u).call(this,e);const t=document.evaluate('preceding::*[@epub:type="pagebreak" or @type="pagebreak" or @role="doc-pagebreak" or contains(@class,"pagebreak")][1]',e,(e=>"epub"===e?"http://www.idpf.org/2007/ops":null),XPathResult.FIRST_ORDERED_NODE_TYPE,null);return t.singleNodeValue instanceof Element&&y(this,r,"m",c).call(this,t.singleNodeValue)?y(this,r,"m",u).call(this,t.singleNodeValue):void 0},f=function(){var e;const t=document.createTreeWalker(document.body,NodeFilter.SHOW_ELEMENT);t.currentNode=null!==(e=document.body.firstElementChild)&&void 0!==e?e:document.body;let n=t.currentNode;for(;n;){if(n instanceof Element&&y(this,r,"m",h).call(this,n))return n;n=t.nextNode()}},m=function(){var e;const t=document.createTreeWalker(document.body,NodeFilter.SHOW_ELEMENT);t.currentNode=null!==(e=document.body.lastElementChild)&&void 0!==e?e:document.body;let n=t.currentNode;for(;n;){if(n instanceof Element&&y(this,r,"m",h).call(this,n))return n;n=t.previousNode()}},h=function(e){if(y(this,r,"m",p).call(this,e))return!1;if(readium.isFixedLayout)return!0;if(e===document.body||e===document.documentElement)return!0;if(!document||!document.documentElement||!document.body)return!1;const t=e.getBoundingClientRect();return y(this,r,"a",o)?t.bottom>0&&t.top0&&t.left!e.classList.contains("docx-table")&&!e.classList.contains("transparent-table")&&!e.classList.contains("plain-table"))),!e||0===e.length)return;e.forEach((e=>{if(function(e){let t=0,n=0;if(e.querySelectorAll("tr").forEach((e=>{e.querySelectorAll("td").forEach((e=>{""===e.textContent.trim()?t++:n++}))})),t>n)return e.classList.add("plain-table"),!0;return!1}(e))return;const t=e.querySelectorAll("thead th").length,n=e.querySelectorAll("tbody tr");if(1===t&&Array.from(n).every((e=>2===e.querySelectorAll("td").length))){const t=n[0].querySelectorAll("td");if(Array.from(t).every((e=>e.querySelector("strong")||Array.from({length:6},((t,n)=>e.querySelector(`h${n+1}`))).some((e=>null!==e)))))return void e.classList.add("plain-table");!function(e){const t=e.querySelector("thead th").textContent,n=Array.from(e.querySelectorAll("tbody td"));e.innerHTML="";const r=document.createElement("tbody"),o=document.createElement("tr"),l=document.createElement("tr");o.innerHTML=" ",l.innerHTML=`${t}`,n.forEach(((e,t)=>{if(t%2==0){const t=document.createElement("td");t.innerHTML=`

${e.textContent}

`,o.appendChild(t)}else{const t=document.createElement("td");Array.from(e.childNodes).forEach((e=>t.appendChild(e.cloneNode(!0)))),l.appendChild(t)}})),r.appendChild(o),r.appendChild(l),e.appendChild(r)}(e)}!function(e){let t=null,n=[];if(e.tHead)t=e.tHead.rows[0],n=Array.from(t.cells);else{const r=e.rows[0];r&&r.querySelector("th, h1, h2, h3, h4, h5, h6, strong")&&(t=r,n=Array.from(r.cells),e.classList.add("has-first-row-headers"))}if(n.length){e.classList.add("has-header");const r=n.map((e=>{var t;let n=(null===(t=e.textContent)||void 0===t?void 0:t.trim())||"";return n.length&&(n+=":"),n}));Array.from(e.rows).filter((e=>e!==t)).forEach((e=>{if(Array.from(e.cells).forEach(((e,t)=>{e.setAttribute("data-th",r[t]||"")})),!e.cells[0].getAttribute("data-th")){e.cells[0].classList.add("mobile-header");const t=document.createElement("h6");t.textContent=e.cells[0].textContent,e.cells[0].textContent="",e.cells[0].appendChild(t)}}))}}(e)}))}}},t={};function n(r){var o=t[r];if(void 0!==o)return o.exports;var l=t[r]={exports:{}};return e[r].call(l.exports,l,l.exports,n),l.exports}n.r=e=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})};var r=n(295);epub=r})(); \ No newline at end of file diff --git a/flutter_readium/example/assets/pubs/free_audiobook.json b/flutter_readium/example/assets/pubs/free_audiobook.json index d1580af1..4dee20a8 100644 --- a/flutter_readium/example/assets/pubs/free_audiobook.json +++ b/flutter_readium/example/assets/pubs/free_audiobook.json @@ -1,7 +1,7 @@ { "readingOrder": [ { - "href": "https://merkur.beta.dbb.dk/opds2/publication/free/49965/Vilde_v_sner_og_s_re_steder_-_Del_1-00001.mp3?format=WebPubAudioOnly", + "href": "Vilde_v_sner_og_s_re_steder_-_Del_1-00001.mp3?format=WebPubAudioOnly", "duration": 1683.692, "title": "Vilde væsner og sære steder, lydfortællinger Lolland.", "type": "audio/mpeg" @@ -10,7 +10,17 @@ "toc": [ { "href": "Vilde_v_sner_og_s_re_steder_-_Del_1-00001.mp3#t=0", - "title": "Vilde væsner og sære steder, lydfortællinger Lolland." + "title": "Forside", + "children": [ + { + "href": "Vilde_v_sner_og_s_re_steder_-_Del_1-00001.mp3#t=12", + "title": "Vilde væsner og sære steder, lydfortællinger Lolland." + }, + { + "href": "Vilde_v_sner_og_s_re_steder_-_Del_1-00001.mp3#t=30", + "title": "Vilde væsner og sære steder, lydfortællinger Lolland - Del 1." + } + ] } ], "@context": "https://readium.org/webpub-manifest/context.jsonld", diff --git a/flutter_readium/example/ios/Podfile.lock b/flutter_readium/example/ios/Podfile.lock index 2409b080..e1bb3271 100644 --- a/flutter_readium/example/ios/Podfile.lock +++ b/flutter_readium/example/ios/Podfile.lock @@ -20,9 +20,6 @@ PODS: - Minizip (1.0.0) - package_info_plus (0.4.5): - Flutter - - path_provider_foundation (0.0.1): - - Flutter - - FlutterMacOS - pointer_interceptor_ios (0.0.1): - Flutter - PromiseKit (8.2.0): @@ -73,7 +70,6 @@ DEPENDENCIES: - flutter_readium (from `.symlinks/plugins/flutter_readium/ios`) - integration_test (from `.symlinks/plugins/integration_test/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - pointer_interceptor_ios (from `.symlinks/plugins/pointer_interceptor_ios/ios`) - PromiseKit (~> 8.1) - ReadiumAdapterGCDWebServer (from `https://raw.githubusercontent.com/readium/swift-toolkit/3.7.0/Support/CocoaPods/ReadiumAdapterGCDWebServer.podspec`) @@ -104,8 +100,6 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/integration_test/ios" package_info_plus: :path: ".symlinks/plugins/package_info_plus/ios" - path_provider_foundation: - :path: ".symlinks/plugins/path_provider_foundation/darwin" pointer_interceptor_ios: :path: ".symlinks/plugins/pointer_interceptor_ios/ios" ReadiumAdapterGCDWebServer: @@ -133,7 +127,6 @@ SPEC CHECKSUMS: integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e Minizip: 188cb3e39a1195c283ae03bf673d182596fefd0b package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 - path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 pointer_interceptor_ios: da06a662d5bfd329602b45b2ab41bc0fb5fdb0f0 PromiseKit: 74e6ab5894856b4762fef547055c809bca91d440 ReadiumAdapterGCDWebServer: 6b7864065eabf708b8d04e6b98ad1a63fbfccf0d diff --git a/flutter_readium/example/macos/Flutter/GeneratedPluginRegistrant.swift b/flutter_readium/example/macos/Flutter/GeneratedPluginRegistrant.swift index 0ec16401..c085c19b 100644 --- a/flutter_readium/example/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/flutter_readium/example/macos/Flutter/GeneratedPluginRegistrant.swift @@ -7,12 +7,10 @@ import Foundation import flutter_readium import package_info_plus -import path_provider_foundation import wakelock_plus func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FlutterReadiumPlugin.register(with: registry.registrar(forPlugin: "FlutterReadiumPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) - PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin")) } diff --git a/flutter_readium/example/pubspec.lock b/flutter_readium/example/pubspec.lock index ecdc13c4..b7936f31 100644 --- a/flutter_readium/example/pubspec.lock +++ b/flutter_readium/example/pubspec.lock @@ -61,10 +61,10 @@ packages: dependency: transitive description: name: build_config - sha256: "4f64382b97504dc2fcdf487d5aae33418e08b4703fc21249e4db6d804a4d0187" + sha256: "4070d2a59f8eec34c97c86ceb44403834899075f66e8a9d59706f8e7834f6f71" url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.3.0" characters: dependency: transitive description: @@ -89,6 +89,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" + url: "https://pub.dev" + source: hosted + version: "1.0.0" collection: dependency: "direct main" description: @@ -125,10 +133,10 @@ packages: dependency: transitive description: name: dart_style - sha256: "15a7db352c8fc6a4d2bc475ba901c25b39fe7157541da4c16eacce6f8be83e49" + sha256: "6f6b30cba0301e7b38f32bdc9a6bdae6f5921a55f0a1eb9450e1e6515645dbb2" url: "https://pub.dev" source: hosted - version: "3.1.5" + version: "3.1.6" dartx: dependency: transitive description: @@ -264,6 +272,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.19.3" + hooks: + dependency: transitive + description: + name: hooks + sha256: "7a08a0d684cb3b8fb604b78455d5d352f502b68079f7b80b831c62220ab0a4f6" + url: "https://pub.dev" + source: hosted + version: "1.0.1" http: dependency: transitive description: @@ -305,26 +321,18 @@ packages: dependency: transitive description: name: json_annotation - sha256: "805fa86df56383000f640384b282ce0cb8431f1a7a2396de92fb66186d8c57df" + sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8 url: "https://pub.dev" source: hosted - version: "4.10.0" - json_schema: - dependency: transitive - description: - name: json_schema - sha256: f37d9c3fdfe8c9aae55fdfd5af815d24ce63c3a0f6a2c1f0982c30f43643fa1a - url: "https://pub.dev" - source: hosted - version: "5.2.2" + version: "4.11.0" json_serializable: dependency: transitive description: name: json_serializable - sha256: "93fba3ad139dab2b1ce59ecc6fdce6da46a42cdb6c4399ecda30f1e7e725760d" + sha256: "44729f5c45748e6748f6b9a57ab8f7e4336edc8ae41fc295070e3814e616a6c0" url: "https://pub.dev" source: hosted - version: "6.12.0" + version: "6.13.0" leak_tracker: dependency: transitive description: @@ -389,6 +397,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.17.0" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: "89e83885ba09da5fdf2cdacc8002a712ca238c28b7f717910b34bcd27b0d03ac" + url: "https://pub.dev" + source: hosted + version: "0.17.4" nested: dependency: transitive description: @@ -397,6 +413,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52" + url: "https://pub.dev" + source: hosted + version: "9.3.0" package_config: dependency: transitive description: @@ -449,10 +473,10 @@ packages: dependency: transitive description: name: path_provider_foundation - sha256: "6d13aece7b3f5c5a9731eaf553ff9dcbc2eff41087fd2df587fd0fed9a3eb0c4" + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" url: "https://pub.dev" source: hosted - version: "2.5.1" + version: "2.6.0" path_provider_linux: dependency: transitive description: @@ -565,22 +589,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.0" - quiver: - dependency: transitive - description: - name: quiver - sha256: ea0b925899e64ecdfbf9c7becb60d5b50e706ade44a85b2363be2a22d88117d2 - url: "https://pub.dev" - source: hosted - version: "3.2.2" - rfc_6901: - dependency: transitive - description: - name: rfc_6901 - sha256: "6a43b1858dca2febaf93e15639aa6b0c49ccdfd7647775f15a499f872b018154" - url: "https://pub.dev" - source: hosted - version: "0.2.1" rxdart: dependency: "direct main" description: @@ -690,14 +698,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" - uri: - dependency: transitive - description: - name: uri - sha256: "889eea21e953187c6099802b7b4cf5219ba8f3518f604a1033064d45b1b8268a" - url: "https://pub.dev" - source: hosted - version: "1.0.0" vector_math: dependency: transitive description: @@ -787,5 +787,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.9.0 <4.0.0" - flutter: ">=3.35.0" + dart: ">=3.10.3 <4.0.0" + flutter: ">=3.38.4" diff --git a/flutter_readium/ios/flutter_readium/Sources/flutter_readium/FlutterReadiumPlugin.swift b/flutter_readium/ios/flutter_readium/Sources/flutter_readium/FlutterReadiumPlugin.swift index 1577127b..c1b59f54 100644 --- a/flutter_readium/ios/flutter_readium/Sources/flutter_readium/FlutterReadiumPlugin.swift +++ b/flutter_readium/ios/flutter_readium/Sources/flutter_readium/FlutterReadiumPlugin.swift @@ -32,6 +32,10 @@ public class FlutterReadiumPlugin: NSObject, FlutterPlugin, ReadiumShared.Warnin /// Timebased Navigator. Can be TTS, Audio or MediaOverlay implementations. internal var timebasedNavigator: FlutterTimebasedNavigator? = nil + /// For EPUB profile, maps document path to a list of all the cssSelectors in the document. + /// This is used to find the current toc item. + private var currentPublicationCssSelectorMap: [String: [String]]? + lazy var fallbackChapterTitle: LocalizedString = LocalizedString.localized([ "en": "Chapter", "da": "Kapitel", @@ -402,7 +406,36 @@ public class FlutterReadiumPlugin: NSObject, FlutterPlugin, ReadiumShared.Warnin public func timebasedNavigator(_: any FlutterTimebasedNavigator, didChangeState state: ReadiumTimebasedState) { print(TAG, "TimebasedNavigator state: \(state)") - timebasedPlayerStateStreamHandler?.sendEvent(state.toJsonString()) + + Task.detached(priority: .high) { + // Find and enrich Locator with current ToC link + if let locator = state.currentLocator, + let time = locator.locations.time?.begin, + let pub = self.currentPublication { + let toc = await pub.getFlattenedToC() + let flattenedTocForHref = toc.filter { + $0.hrefPath == locator.href.path + } + var matchedTocItem: Link? + for tocLink in flattenedTocForHref { + guard let tocTime = tocLink.timeFragmentBegin else { + continue + } + // Save to matchedTocItem, unless timeFromFragment is past time + if tocTime > time { + break + } + matchedTocItem = tocLink + } + if let tocLinkHref = matchedTocItem?.href { + print(TAG, "Found matching TOC item: \(tocLinkHref)") + state.currentLocator?.locations.otherLocations["toc"] = tocLinkHref + } + } + Task { @MainActor [state] in + self.timebasedPlayerStateStreamHandler?.sendEvent(state.toJsonString()) + } + } } public func timebasedNavigator(_: any FlutterTimebasedNavigator, encounteredError error: any Error, withDescription description: String?) { @@ -510,6 +543,68 @@ extension FlutterReadiumPlugin { currentPublication?.close() currentPublication = nil currentPublicationUrlStr = nil + currentPublicationCssSelectorMap = [:] + } + } +} + +/// Extension for finding current ToC location +extension FlutterReadiumPlugin { + + /// Find the current table of content item from a locator. + func currentTocLinkFromLocator(_ locator: Locator) async throws -> Link? { + let start = CFAbsoluteTimeGetCurrent() + guard let publication = currentPublication else { + debugPrint(TAG, ":currentTocLinkFromLocator, no currentPublication") + return nil } + + guard let cssSelector = await publication.findCssSelectorForLocator(locator: locator) else { + debugPrint(TAG, ":epubFindCurrentToc, could not find cssSelector from locator") + return nil + } + + let cleanHrefPath = locator.href.path + + let contentIds = try await epubGetAllDocumentCssSelectors(hrefPath: cleanHrefPath) + + var idx = contentIds.firstIndex(of: cssSelector) + if idx == nil { + debugPrint(TAG, ":currentTocLinkFromLocator cssSelector:\(cssSelector) not found in current href, assuming 0") + idx = 0 + } + + let toc = Dictionary( + uniqueKeysWithValues: + await publication.getFlattenedToC() + .filter { RelativeURL(epubHREF: $0.href)?.path == cleanHrefPath } + .compactMap { item -> (Int, Link)? in + let fragment = RelativeURL(epubHREF: item.href)?.fragment ?? "" + guard let index = contentIds.firstIndex(of: "#\(fragment)") else { return nil } + return (index, item) + } + ) + + let tocItem = (toc.filter { $0.key <= idx! } + .sorted { $0.key < $1.key } + .last?.value + ?? toc.sorted { $0.key < $1.key }.first?.value) + return tocItem + } + + /// Get all cssSelectors for an EPUB file. + func epubGetAllDocumentCssSelectors(hrefPath: String) async throws -> [String] { + if currentPublicationCssSelectorMap == nil { + currentPublicationCssSelectorMap = [:] + } + + if let cached = currentPublicationCssSelectorMap?[hrefPath] { + return cached + } + + let selectors = await currentPublication?.findAllCssSelectors(hrefRelativePath: hrefPath) ?? [] + + currentPublicationCssSelectorMap?[hrefPath] = selectors + return selectors } } diff --git a/flutter_readium/ios/flutter_readium/Sources/flutter_readium/ReadiumReaderView.swift b/flutter_readium/ios/flutter_readium/Sources/flutter_readium/ReadiumReaderView.swift index 9069dcaa..230d01d6 100644 --- a/flutter_readium/ios/flutter_readium/Sources/flutter_readium/ReadiumReaderView.swift +++ b/flutter_readium/ios/flutter_readium/Sources/flutter_readium/ReadiumReaderView.swift @@ -35,7 +35,6 @@ public class ReadiumReaderView: NSObject, FlutterPlatformView, EPUBNavigatorDele private let channel: ReadiumReaderChannel private let _view: UIView private let readiumViewController: EPUBNavigatorViewController - private var isVerticalScroll = false private var hasSentReady = false var publicationIdentifier: String? @@ -91,10 +90,10 @@ public class ReadiumReaderView: NSObject, FlutterPlatformView, EPUBNavigatorDele config.preloadPreviousPositionCount = 2 config.preloadNextPositionCount = 4 config.debugState = true - + // TODO: Use experimentalPositioning for now. It places highlights on z-index -1 behind text, instead of in-front. config.decorationTemplates = HTMLDecorationTemplate.defaultTemplates(alpha: 1.0, experimentalPositioning: true) - + // TODO: This is a PoC for adding custom editing actions, like user highlights. It should be configurable from Flutter. config.editingActions = [.lookup, .translate, EditingAction(title: "Custom Highlight Action", action: #selector(onCustomEditingAction))] @@ -252,24 +251,29 @@ public class ReadiumReaderView: NSObject, FlutterPlatformView, EPUBNavigatorDele } private func setUserPreferences(preferences: EPUBPreferences) { - isVerticalScroll = preferences.scroll ?? false self.readiumViewController.submitPreferences(preferences) } private func emitOnPageChanged(locator: Locator) -> Void { - let json = locator.jsonString ?? "null" - print(TAG, "emitOnPageChanged:locator=\(String(describing: locator))") - Task.detached(priority: .high) { [isVerticalScroll] in - guard let locatorWithFragments = await self.getLocatorFragments(json, isVerticalScroll) else { - print(TAG, "emitOnPageChanged failed!") - return + Task.detached(priority: .high) { [locator] in + /// Enrich Locator with PageInformation and ToC. + var resultLocator = locator + if let pageInfo = await self.getPageInformation() { + resultLocator.locations.otherLocations.merge(pageInfo.otherLocations, uniquingKeysWith: { lhs, rhs in lhs }) } + if let tocLink = try? await FlutterReadiumPlugin.instance?.currentTocLinkFromLocator(resultLocator) { + resultLocator.title = tocLink.title + resultLocator.locations.otherLocations["toc"] = tocLink.href + } + + /// Immutable ref, so that we can use it on the main thread + let finalLocator = resultLocator await MainActor.run() { - self.channel.onPageChanged(locator: locatorWithFragments) + self.channel.onPageChanged(locator: finalLocator) FlutterReadiumPlugin.instance?.textLocatorStreamHandler? - .sendEvent(locatorWithFragments.jsonString) + .sendEvent(finalLocator.jsonString) } } } @@ -283,61 +287,34 @@ public class ReadiumReaderView: NSObject, FlutterPlatformView, EPUBNavigatorDele } } - internal func getLocatorFragments(_ locatorJson: String, _ isVerticalScroll: Bool) async -> Locator? { - switch await self.evaluateJavascript("window.epubPage.getLocatorFragments(\(locatorJson), \(isVerticalScroll));") { - case .success(let jresult): - let locatorWithFragments = try! Locator(json: jresult as? Dictionary, warnings: readiumBugLogger)! - return locatorWithFragments - case .failure(let err): - print(TAG, "getLocatorFragments failed! \(err)") - return nil - } - } - - private func scrollTo(locations: Locator.Locations, toStart: Bool) async -> Void { - let json = locations.jsonString ?? "null" - print(TAG, "scrollTo: Go to locations \(json), toStart: \(toStart)") - - let _ = await evaluateJavascript("window.epubPage.scrollToLocations(\(json),\(isVerticalScroll),\(toStart));") + internal func getPageInformation() async -> PageInformation? { + switch await self.evaluateJavascript("window.epubPage.getPageInformation();") { + case .success(let jresult): + let pageInfo = PageInformation.fromJson(jresult as? Dictionary ?? Dictionary()) + return pageInfo + case .failure(let err): + print(TAG, "getPageInformation failed! \(err)") + return nil + } } func goToLocator(locator: Locator, animated: Bool) async -> Void { - let locations = locator.locations - let shouldScroll = canScroll(locations: locations) - let shouldGo = readiumViewController.currentLocation?.href != locator.href let readiumViewController = self.readiumViewController - if shouldGo { - print(TAG, "goToLocator: Go to \(locator.href)") - let goToSuccees = await readiumViewController.go(to: locator, options: NavigatorGoOptions(animated: animated)) - if (goToSuccees && shouldScroll) { - await self.scrollTo(locations: locations, toStart: false) - self.emitOnPageChanged() - } - } else { - print(TAG, "goToLocator: Already there, Scroll to \(locator.href)") - if (shouldScroll) { - await self.scrollTo(locations: locations, toStart: false) - self.emitOnPageChanged() - } - } + let goToSuccees = await readiumViewController.go(to: locator, options: NavigatorGoOptions(animated: animated)) + //self.emitOnPageChanged() } func justGoToLocator(_ locator: Locator, animated: Bool) async -> Bool { return await readiumViewController.go(to: locator, options: NavigatorGoOptions(animated: animated)) } - private func setLocation(locator: Locator, isAudioBookWithText: Bool) async -> Result { - let json = locator.jsonString ?? "null" - - return await evaluateJavascript("window.epubPage.setLocation(\(json), \(isAudioBookWithText));") - } - private func emitOnPageChanged() { guard let locator = readiumViewController.currentLocation else { print(TAG, "emitOnPageChanged: currentLocation = nil!") return } + print(TAG, "emitOnPageChanged: Calling navigator:locationDidChange.") navigator(readiumViewController, locationDidChange: locator) } @@ -353,7 +330,6 @@ public class ReadiumReaderView: NSObject, FlutterPlatformView, EPUBNavigatorDele Task.detached(priority: .high) { await self.goToLocator(locator: locator, animated: animated) - let _ = await self.setLocation(locator: locator, isAudioBookWithText: isAudioBookWithText) await MainActor.run() { result(true) } @@ -381,60 +357,6 @@ public class ReadiumReaderView: NSObject, FlutterPlatformView, EPUBNavigatorDele } } break - case "setLocation": - let args = call.arguments as! [Any] - print(TAG, "onMethodCall[setLocation] locator = \(args[0] as! String)") - let locator = try! Locator(jsonString: args[0] as! String, warnings: readiumBugLogger)! - let isAudioBookWithText = args[1] as? Bool ?? false - Task.detached(priority: .high) { - let _ = await self.setLocation(locator: locator, isAudioBookWithText: isAudioBookWithText) - return await MainActor.run() { - result(true) - } - } - break - case "getLocatorFragments": - let args = call.arguments as? String ?? "null" - Task.detached(priority: .high) { - do { - let data = try await self.evaluateJavascript("window.epubPage.getLocatorFragments(\(args), true);").get() - await MainActor.run() { - return result(data) - } - } catch (let err) { - print(TAG, "getLocatorFragments error \(err)") - await MainActor.run() { - return result(false) - } - } - } - break - case "getCurrentLocator": - let args = call.arguments as? String ?? "null" - print(TAG, "onMethodCall[currentLocator] args = \(args)") - Task.detached(priority: .high) { [isVerticalScroll] in - let json = await self.readiumViewController.currentLocation?.jsonString ?? nil - if (json == nil) { - await MainActor.run() { - return result(nil) - } - } - let data = await self.getLocatorFragments(json!, isVerticalScroll) - await MainActor.run() { - return result(data?.jsonString) - } - } - break - case "isLocatorVisible": - let args = call.arguments as! String - print(TAG, "onMethodCall[isLocatorVisible] locator = \(args)") - let locator = try! Locator(jsonString: args, warnings: readiumBugLogger)! - if locator.href != self.readiumViewController.currentLocation?.href { - result(false) - return - } - evaluateJSReturnResult("window.epubPage.isLocatorVisible(\(args));", result: result) - break case "setPreferences": let args = call.arguments as! [String: String] print(TAG, "onMethodCall[setPreferences] args = \(args)") @@ -504,8 +426,3 @@ func initUserScripts(registrar: FlutterPluginRegistrar) { /// Add simple script used by our JS to detect OS userScripts.append(WKUserScript(source: "const isAndroid=false,isIos=true;", injectionTime: .atDocumentStart, forMainFrameOnly: false)) } - -private func canScroll(locations: Locator.Locations?) -> Bool { - guard let locations = locations else { return false } - return locations.domRange != nil || locations.cssSelector != nil || locations.progression != nil -} diff --git a/flutter_readium/ios/flutter_readium/Sources/flutter_readium/model/FlutterMediaOverlay.swift b/flutter_readium/ios/flutter_readium/Sources/flutter_readium/model/FlutterMediaOverlay.swift index 1c6846a8..e0e669d5 100644 --- a/flutter_readium/ios/flutter_readium/Sources/flutter_readium/model/FlutterMediaOverlay.swift +++ b/flutter_readium/ios/flutter_readium/Sources/flutter_readium/model/FlutterMediaOverlay.swift @@ -56,16 +56,16 @@ struct FlutterMediaOverlay { return nil } - static func fromJson(_ json: [String: Any], atPosition position: Int) -> FlutterMediaOverlay? { + static func fromJson(_ json: [String: Any], atPosition position: Int, atTocHref: String? = nil) -> FlutterMediaOverlay? { guard let topNarration = json["narration"] as? [[String: Any]] else { return nil } var acc: [FlutterMediaOverlayItem] = [] for obj in topNarration { - if let item = FlutterMediaOverlayItem.fromJson(obj, atPosition: position) { + if let item = FlutterMediaOverlayItem.fromJson(obj, atPosition: position, atTocHref: atTocHref) { acc.append(item) } // recurse if nested containers also have "narration" - if let nested = FlutterMediaOverlay.fromJson(obj, atPosition: position) { + if let nested = FlutterMediaOverlay.fromJson(obj, atPosition: position, atTocHref: atTocHref) { acc.append(contentsOf: nested.items) } } @@ -94,15 +94,20 @@ struct FlutterMediaOverlayItem { let textFile: String let textId: String - init(audio: String, text: String, position: Int) { + let tocTitle: String? + let tocHref: String? + + init(audio: String, text: String, position: Int, tocTitle: String? = nil, tocHref: String? = nil) { self.audio = audio self.text = text self.position = position + self.tocTitle = tocTitle + self.tocHref = tocHref self.audioFile = audio.split(separator: "#", maxSplits: 1).first.map(String.init) ?? audio - self.audioFragment = audio.split(separator: "#", maxSplits: 1).last.map(String.init) ?? "" + self.audioFragment = audio.split(separator: "#", maxSplits: 1).getOrNil(1).map(String.init) ?? "" self.audioTime = audioFragment.hasPrefix("t=") ? String(audioFragment.dropFirst(2)) : nil self.textFile = text.split(separator: "#", maxSplits: 1).first.map(String.init) ?? "" - self.textId = text.split(separator: "#", maxSplits: 1).last.map(String.init) ?? "" + self.textId = text.split(separator: "#", maxSplits: 1).getOrNil(1).map(String.init) ?? "" self.audioMediaType = switch (audioFile.split(separator: ".").last) { case "opus" : MediaType.opus @@ -120,6 +125,10 @@ struct FlutterMediaOverlayItem { } } + func copyWith(tocTitle: String?, tocHref: String?) -> FlutterMediaOverlayItem { + return FlutterMediaOverlayItem(audio: audio, text: text, position: position, tocTitle: tocTitle, tocHref: tocHref) + } + static func == (lhs: FlutterMediaOverlayItem, rhs: FlutterMediaOverlayItem) -> Bool { return lhs.audio == rhs.audio && lhs.text == rhs.text && lhs.position == rhs.position } @@ -146,12 +155,16 @@ struct FlutterMediaOverlayItem { var locator = Locator( href: href, mediaType: MediaType.xhtml, + title: tocTitle, locations: .init( fragments: frag.map { ["#\($0)"] } ?? [], ) ) if (frag != nil) { - locator.locations.otherLocations = ["cssSelector": "#\(frag!)"] + locator.locations.otherLocations["cssSelector"] = "#\(frag!)" + } + if (tocHref != nil) { + locator.locations.otherLocations["toc"] = tocHref } return locator } @@ -185,11 +198,11 @@ struct FlutterMediaOverlayItem { } // MARK: JSON - static func fromJson(_ json: [String: Any], atPosition position: Int) -> FlutterMediaOverlayItem? { + static func fromJson(_ json: [String: Any], atPosition position: Int, atTocHref: String?) -> FlutterMediaOverlayItem? { guard let audio = json["audio"] as? String, !audio.isEmpty, let text = json["text"] as? String, !text.isEmpty else { return nil } - return FlutterMediaOverlayItem(audio: audio, text: text, position: position) + return FlutterMediaOverlayItem(audio: audio, text: text, position: position, tocHref: atTocHref) } } diff --git a/flutter_readium/ios/flutter_readium/Sources/flutter_readium/model/PageInformation.swift b/flutter_readium/ios/flutter_readium/Sources/flutter_readium/model/PageInformation.swift new file mode 100644 index 00000000..5891be7b --- /dev/null +++ b/flutter_readium/ios/flutter_readium/Sources/flutter_readium/model/PageInformation.swift @@ -0,0 +1,57 @@ +import Foundation + +final class PageInformation { + + let pageIndex: Int64? + let totalPages: Int64? + let physicalPageIndex: String? + let cssSelector: String? + + init(pageIndex: Int64?, totalPages: Int64?, physicalPageIndex: String?, cssSelector: String?) { + self.pageIndex = pageIndex + self.totalPages = totalPages + self.physicalPageIndex = physicalPageIndex + self.cssSelector = cssSelector + } + + var otherLocations: [String: Any] { + var res: [String: Any] = [:] + + if let pageIndex, let totalPages { + res["currentPage"] = pageIndex + res["totalPages"] = totalPages + } + + if let physicalPageIndex, + !physicalPageIndex.isEmpty { + res["physicalPage"] = physicalPageIndex + } + + if let cssSelector, + !cssSelector.isEmpty { + res["cssSelector"] = cssSelector + } + + return res + } + + static func fromJson(_ jsonString: String) throws -> PageInformation { + let data = Data(jsonString.utf8) + let object = try JSONSerialization.jsonObject(with: data) as? [String: Any] ?? [:] + return fromJson(object) + } + + static func fromJson(_ json: [String: Any]) -> PageInformation { + let pageIndex = json["pageIndex"] as? NSNumber + let totalPages = json["totalPages"] as? NSNumber + let physicalPageIndex = json["physicalPageIndex"] as? String + let cssSelector = json["cssSelector"] as? String + + return PageInformation( + pageIndex: pageIndex?.int64Value, + totalPages: totalPages?.int64Value, + physicalPageIndex: physicalPageIndex, + cssSelector: cssSelector + ) + } +} diff --git a/flutter_readium/ios/flutter_readium/Sources/flutter_readium/navigator/FlutterAudioNavigator.swift b/flutter_readium/ios/flutter_readium/Sources/flutter_readium/navigator/FlutterAudioNavigator.swift index 68f935dc..48ed76c8 100644 --- a/flutter_readium/ios/flutter_readium/Sources/flutter_readium/navigator/FlutterAudioNavigator.swift +++ b/flutter_readium/ios/flutter_readium/Sources/flutter_readium/navigator/FlutterAudioNavigator.swift @@ -247,8 +247,8 @@ public class FlutterAudioNavigator: FlutterTimebasedNavigator, AudioNavigatorDel /// Fetch MediaPlaybackState and convert it to TimebasedState var playerState = info.state.asTimebasedState - if (info.state == .paused && info.progress >= 1 && info.resourceIndex == self.publication.manifest.readingOrder.count - 1) { - /// If paused at progress 1 of the last resource in readingOrder, we have to assume the book has ended. + if (info.state == .paused && info.progress >= 1.0 && info.resourceIndex == self.publication.manifest.readingOrder.count - 1) { + /// If paused at progress 1 of the last resource in readingOrder, we can assume the book has ended. playerState = .ended } diff --git a/flutter_readium/ios/flutter_readium/Sources/flutter_readium/navigator/FlutterMediaOverlayNavigator.swift b/flutter_readium/ios/flutter_readium/Sources/flutter_readium/navigator/FlutterMediaOverlayNavigator.swift index 44c73fca..f770fe11 100644 --- a/flutter_readium/ios/flutter_readium/Sources/flutter_readium/navigator/FlutterMediaOverlayNavigator.swift +++ b/flutter_readium/ios/flutter_readium/Sources/flutter_readium/navigator/FlutterMediaOverlayNavigator.swift @@ -24,16 +24,8 @@ public class FlutterMediaOverlayNavigator : FlutterAudioNavigator public override func initNavigator() async -> Void { debugPrint(OTAG, "Publication with Synchronized Narration reading-order found!") - let narrationLinks = publication.readingOrder.compactMap { - var link = $0.alternates.filterByMediaType(MediaType("application/vnd.syncnarr+json")!).first - link?.title = $0.title - return link - } - let narrationJson = await narrationLinks.asyncCompactMap { try? await publication.get($0)?.readAsJSONObject().get() } - let mediaOverlays = narrationJson.enumerated().compactMap({ idx, json in FlutterMediaOverlay.fromJson(json, atPosition: idx) }) - - // Assert that we did not lose any MediaOverlays during JSON deserialization. - assert(mediaOverlays.count == narrationLinks.count) + let narrationLinks = publication.narrationLinks + let mediaOverlays = await publication.getMediaOverlays() let audioReadingOrder = mediaOverlays.enumerated().map { (idx, narr) in narrationLinks.getOrNil(idx).map { diff --git a/flutter_readium/ios/flutter_readium/Sources/flutter_readium/utils/ReadiumExtensions.swift b/flutter_readium/ios/flutter_readium/Sources/flutter_readium/utils/ReadiumExtensions.swift index e1f3f9f2..1a1cfe98 100644 --- a/flutter_readium/ios/flutter_readium/Sources/flutter_readium/utils/ReadiumExtensions.swift +++ b/flutter_readium/ios/flutter_readium/Sources/flutter_readium/utils/ReadiumExtensions.swift @@ -22,6 +22,160 @@ extension Publication { var containsMediaOverlays: Bool { self.readingOrder.contains(where: { $0.alternates.contains(where: { $0.mediaType?.matches(MediaType("application/vnd.syncnarr+json")) == true })}) } + + var narrationLinks: [Link] { + return self.readingOrder.compactMap { + var link = $0.alternates.filterByMediaType(MediaType("application/vnd.syncnarr+json")!).first + link?.title = $0.title + return link + } + } + + func getMediaOverlays() async -> [FlutterMediaOverlay] { + if (!containsMediaOverlays) { + return [] + } + + let narrationLinks = self.narrationLinks + + let toc: [(String, Link)] = (await getFlattenedToC()).map { ($0.href, $0) } + var lastTocMatch: (String, Link)? = nil + + let narrationJson = await narrationLinks.asyncCompactMap { try? await self.get($0)?.readAsJSONObject().get() } + let mediaOverlays = narrationJson.enumerated().compactMap({ idx, json in + FlutterMediaOverlay.fromJson(json, atPosition: idx, atTocHref: nil) + }).map({ + let items = $0.items.map { item in + // Find best matching title from ToC (via text URL) + if let match = toc.first(where: { tocItem in tocItem.0 == item.text }) { + lastTocMatch = match + return item.copyWith(tocTitle: match.1.title, tocHref: match.1.href) + } else if (lastTocMatch?.1 != nil && lastTocMatch?.0.substringBeforeLast("#") == item.textFile) { + return item.copyWith(tocTitle: lastTocMatch?.1.title, tocHref: lastTocMatch?.1.href) + } + return item + } + return FlutterMediaOverlay(items: items) + }) + + // Assert that we did not lose any MediaOverlays during JSON deserialization. + assert(mediaOverlays.count == narrationLinks.count) + + return mediaOverlays + } + + func getFlattenedToC() async -> [Link] { + switch await self.tableOfContents() { + case .success(let toc): + return toc.flatMap{ $0.flattened } + case .failure(let err): + debugPrint("failed to retrieve ToC: \(err)") + return [] + } + } + + func searchInContentForQuery(_ query: String) async -> [LocatorCollection] { + guard let searchService: SearchService = findService(SearchService.self) else { + debugPrint("No SearchService available") + return [] + } + var collections: [LocatorCollection] = [] + switch await searchService.search(query: query, options: .init()) { + case .failure(let err): + switch err { + case .badQuery(let queryErr): + debugPrint("Search failed, bad query: \(queryErr)") + case .reading(let readErr): + debugPrint("Search failed, reading error: \(readErr)") + case .publicationNotSearchable: + debugPrint("Search failed, publication is not searchable") + } + case .success(let iterator): + _ = await iterator.forEach { collection in + collections.append(collection) + } + } + return collections + } + + /** + * Helper for getting all cssSelectors for a HTML document in the Publication. + */ + func findAllCssSelectors(hrefRelativePath: String) async -> [String] { + if (!self.conforms(to: Publication.Profile.epub)) { + debugPrint("This only works for EPUBs") + return [] + } + guard let contentService: ContentService = findService(ContentService.self) else { + debugPrint("No ContentService available") + return [] + } + let cleanHref = hrefRelativePath, + startLocator = Locator(href: RelativeURL(string: cleanHref)!, mediaType: MediaType.xhtml) + + guard let content = contentService.content(from: startLocator)?.iterator() else { + debugPrint("No content iterator obtained from ContentService") + return [] + } + + var ids = [] as [String] + + do { + while let element = try await content.next() { + if (element.locator.href.path != cleanHref) { + break + } + + if let cssSelector = element.locator.locations.cssSelector.takeIf({ $0.hasPrefix("#") }) { + ids.append(cssSelector) + debugPrint("findAllCssSelectors: \(element.locator.href.path),id: \(cssSelector)") + } + } + } catch (let err) { + debugPrint("ContentService failed to fetch next element: \(err)") + } + return ids + } + + /** + * Find the cssSelector for a locator. If it already have one return it, otherwise we need to look it up. + * We find it by rewinding in the ContentService from current Locator, until we hit an #id selector. + */ + func findCssSelectorForLocator(locator: Locator) async -> String? { + if locator.locations.cssSelector?.hasPrefix("#") == true { + return locator.locations.cssSelector + } else { + debugPrint("findCssSelectorForLocator: there's work to do!") + } + + guard let contentService: ContentService = findService(ContentService.self) else { + debugPrint("No ContentService available") + return nil + } + + let cleanPath = locator.href.path + let content = contentService.content(from: locator)?.iterator() + if (content == nil) { + debugPrint("No content iterator obtained from ContentService") + return nil + } + let locatorProgression = locator.locations.progression ?? 0.0 + + while let element: ContentElement? = try? await content?.previous() { + if (element == nil) { + // Nore more content to rewind through. + break + } + // Return first content element with an #id cssSelector + if let cssSelector = element?.locator.locations.cssSelector.takeIf({ $0.hasPrefix("#") }) { + debugPrint("findCssSelector: \(element?.locator.href.path ?? ""), \(element?.locator.locations.progression ?? 0.0), \(element?.locator.locations.cssSelector ?? "")") + return cssSelector.split(separator: " ").first?.lowercased() + } else { + debugPrint("findCssSelector: skip \(element?.locator.locations.cssSelector ?? "")") + } + } + return nil + } } extension MediaPlaybackState { @@ -54,6 +208,37 @@ extension Link { throw JSONError.parsing(Self.self) } } + + /// Returns only the path part of the Link href. + var hrefPath: String? { + return URL(string: href)?.path + } + + /// Recursively flattens the Link and its children. + var flattened: [Link] { + return [self] + children.flatMap{ $0.flattened } + } + + /// Gets the time-fragment if part of the Link. + var timeFragment: String? { + if let url = URL(string: self.href), + let timeFragment = url.fragment?.split(separator: "&").first(where: { $0.hasPrefix("t=") }), + let timeComponent = timeFragment.split(separator: "=").last { + return String(timeComponent) + } else { + return nil + } + } + + /// Gets the Begin part of a time-fragment as Double in in the Link. + var timeFragmentBegin: Double? { + if let timeComponent = timeFragment, + let timeBegin = timeComponent.split(separator: ",").first { + return Double(timeBegin) + } else { + return nil + } + } } extension Decoration { diff --git a/flutter_readium/lib/reader_channel.dart b/flutter_readium/lib/reader_channel.dart index 0f65ff68..a090c2ec 100644 --- a/flutter_readium/lib/reader_channel.dart +++ b/flutter_readium/lib/reader_channel.dart @@ -4,18 +4,7 @@ import 'dart:convert'; import 'package:flutter/services.dart'; import 'package:flutter_readium/flutter_readium.dart'; -enum _ReaderChannelMethodInvoke { - applyDecorations, - go, - goLeft, - goRight, - getCurrentLocator, - getLocatorFragments, - setLocation, - isLocatorVisible, - dispose, - setPreferences, -} +enum _ReaderChannelMethodInvoke { applyDecorations, go, goLeft, goRight, dispose, setPreferences } /// Internal use only. /// Used by ReadiumReaderWidget to talk to the native widget. @@ -50,24 +39,6 @@ class ReadiumReaderChannel extends MethodChannel { return _invokeMethod(_ReaderChannelMethodInvoke.goRight, animated); } - /// Get locator fragments for the given [locator]. - Future getLocatorFragments(final Locator locator) { - R2Log.d('locator: ${locator.toString()}'); - - return _invokeMethod( - _ReaderChannelMethodInvoke.getLocatorFragments, - json.encode(locator.toJson()), - ).then((final value) => Locator.fromJson(json.decode(value))).onError((final error, final _) { - R2Log.e(error ?? 'Unknown Error'); - - throw ReadiumException('getLocatorFragments failed $locator'); - }); - } - - /// Set the current location to the given [locator]. - Future setLocation(final Locator locator, final bool isAudioBookWithText) async => - _invokeMethod(_ReaderChannelMethodInvoke.setLocation, [json.encode(locator), isAudioBookWithText]); - /// Set EPUB preferences. Future setEPUBPreferences(EPUBPreferences preferences) async { await _invokeMethod(_ReaderChannelMethodInvoke.setPreferences, preferences.toJson()); @@ -78,18 +49,6 @@ class ReadiumReaderChannel extends MethodChannel { return await _invokeMethod(_ReaderChannelMethodInvoke.applyDecorations, [id, decorations.map((d) => d.toJson())]); } - /// Get the current locator. - Future getCurrentLocator() async => await _invokeMethod( - _ReaderChannelMethodInvoke.getCurrentLocator, - [], - ).then((locStr) => locStr != null ? Locator.fromJson(json.decode(locStr) as Map) : null); - - /// Check if a locator is currently visible on screen. - Future isLocatorVisible(final Locator locator) => _invokeMethod( - _ReaderChannelMethodInvoke.isLocatorVisible, - json.encode(locator), - ).then((final isVisible) => isVisible!).onError((final error, final _) => true); - Future dispose() async { try { await _invokeMethod(_ReaderChannelMethodInvoke.dispose); diff --git a/flutter_readium/lib/reader_widget.dart b/flutter_readium/lib/reader_widget.dart index 48415b5a..edb6d086 100644 --- a/flutter_readium/lib/reader_widget.dart +++ b/flutter_readium/lib/reader_widget.dart @@ -7,7 +7,7 @@ import 'package:flutter/material.dart' as mq show Orientation; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; -import 'package:flutter_readium_platform_interface/flutter_readium_platform_interface.dart'; +import 'package:flutter_readium/flutter_readium.dart'; import 'package:rxdart/rxdart.dart'; import 'package:wakelock_plus/wakelock_plus.dart'; @@ -53,6 +53,7 @@ class _ReadiumReaderWidgetState extends State implements Re final _isReadyCompleter = Completer(); final _readium = FlutterReadiumPlatform.instance; + final FlutterReadium _flutterReadium = FlutterReadium(); mq.Orientation? _lastOrientation; late Widget _readerWidget; @@ -124,7 +125,7 @@ class _ReadiumReaderWidgetState extends State implements Re // TODO: Find a better way to do this, maybe a `lastVisibleLocator` ? if (_readium.defaultPreferences?.verticalScroll != true) { await _channel?.goRight(animated: false); - final loc = await _channel?.getCurrentLocator(); + final loc = await _flutterReadium.onTextLocatorChanged.first; currentHref = getTextLocatorHrefWithTocFragment(loc); } @@ -155,21 +156,6 @@ class _ReadiumReaderWidgetState extends State implements Re } } - @override - Future getLocatorFragments(final Locator locator) async { - R2Log.d('getLocatorFragments: $locator'); - - await _awaitNativeViewReady(); - - return await _channel?.getLocatorFragments(locator); - } - - @override - Future getCurrentLocator() async { - R2Log.d('GetCurrentLocator()'); - return _channel?.getCurrentLocator(); - } - @override Future setEPUBPreferences(EPUBPreferences preferences) async { _channel?.setEPUBPreferences(preferences); @@ -288,10 +274,6 @@ class _ReadiumReaderWidgetState extends State implements Re }); } - Future _awaitNativeViewReady() { - return _isReadyCompleter.future; - } - /// Gets a Locator's href with toc fragment appended as identifier String? getTextLocatorHrefWithTocFragment(Locator? locator) { if (locator == null) { diff --git a/flutter_readium/lib/reader_widget_web.dart b/flutter_readium/lib/reader_widget_web.dart index 944fa016..54e83eb0 100644 --- a/flutter_readium/lib/reader_widget_web.dart +++ b/flutter_readium/lib/reader_widget_web.dart @@ -75,13 +75,6 @@ class _ReadiumReaderWidgetState extends State implements Re JsPublicationChannel.goRight(); } - @override - // ignore: prefer_expression_function_bodies - Future getLocatorFragments(final Locator locator) async { - // Implement this method if needed - return null; - } - @override Future skipToPrevious({final bool animated = true}) async { R2Log.d('skipToPrevious not implemented in web version'); @@ -92,12 +85,6 @@ class _ReadiumReaderWidgetState extends State implements Re R2Log.d('skipToNext not implemented in web version'); } - @override - Future getCurrentLocator() async { - R2Log.d('getCurrentLocator not implemented in web version'); - return null; - } - @override Future setEPUBPreferences(EPUBPreferences preferences) async { R2Log.d('setEPUBPreferences not implemented in web version'); diff --git a/flutter_readium/package-lock.json b/flutter_readium/package-lock.json index f8051f66..3a334d4f 100644 --- a/flutter_readium/package-lock.json +++ b/flutter_readium/package-lock.json @@ -415,9 +415,9 @@ } }, "node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", "dependencies": { @@ -1399,16 +1399,6 @@ "dev": true, "license": "MIT" }, - "node_modules/randombytes": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", - "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "safe-buffer": "^5.1.0" - } - }, "node_modules/rechoir": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz", @@ -1473,27 +1463,6 @@ "node": ">=8" } }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/schema-utils": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", @@ -1527,16 +1496,6 @@ "node": ">=10" } }, - "node_modules/serialize-javascript": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", - "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "randombytes": "^2.1.0" - } - }, "node_modules/shallow-clone": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", @@ -1691,16 +1650,15 @@ } }, "node_modules/terser-webpack-plugin": { - "version": "5.3.16", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.16.tgz", - "integrity": "sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q==", + "version": "5.3.17", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.17.tgz", + "integrity": "sha512-YR7PtUp6GMU91BgSJmlaX/rS2lGDbAF7D+Wtq7hRO+MiljNmodYvqslzCFiYVAgW+Qoaaia/QUIP4lGXufjdZw==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "jest-worker": "^27.4.5", "schema-utils": "^4.3.0", - "serialize-javascript": "^6.0.2", "terser": "^5.31.1" }, "engines": { diff --git a/flutter_readium_platform_interface/lib/src/extensions/readium_string_extensions.dart b/flutter_readium_platform_interface/lib/src/extensions/readium_string_extensions.dart index 4fbeccd9..cb4c6522 100644 --- a/flutter_readium_platform_interface/lib/src/extensions/readium_string_extensions.dart +++ b/flutter_readium_platform_interface/lib/src/extensions/readium_string_extensions.dart @@ -53,6 +53,4 @@ extension ReadiumStringExtension on String { /// /// Returns `null` if path couldn't be retrieved from uri. String? get path => Uri.tryParse(this)?.path; - - String stripLeadingSlash() => startsWith('/') ? substring(1) : this; } diff --git a/flutter_readium_platform_interface/lib/src/extensions/strings.dart b/flutter_readium_platform_interface/lib/src/extensions/strings.dart index f950111c..3504e9c1 100644 --- a/flutter_readium_platform_interface/lib/src/extensions/strings.dart +++ b/flutter_readium_platform_interface/lib/src/extensions/strings.dart @@ -105,6 +105,13 @@ extension StringExtension on String { /// /// @sample samples.text.Strings.take String takeWhile(bool Function(String) predicate) => characters.takeWhile(predicate).string; + + (String, String?) splitPathAndFragment() { + final components = split('#'); + final path = components.firstOrDefault(this); + final fragment = (components.length > 1 && components[1].isNotEmpty) ? components[1] : null; + return (path, fragment); + } } extension StringHashExtension on String { diff --git a/flutter_readium_platform_interface/lib/src/reader/reader_widget_interface.dart b/flutter_readium_platform_interface/lib/src/reader/reader_widget_interface.dart index 642e5c51..7bd2f6a6 100644 --- a/flutter_readium_platform_interface/lib/src/reader/reader_widget_interface.dart +++ b/flutter_readium_platform_interface/lib/src/reader/reader_widget_interface.dart @@ -16,12 +16,6 @@ abstract class ReadiumReaderWidgetInterface { /// Skip to next chapter (toc) Future skipToNext({final bool animated = true}); - /// Gets the current Navigator's locator. - Future getCurrentLocator(); - - /// Get a locator with relevant fragments - Future getLocatorFragments(final Locator locator); - /// Set EPUB preferences Future setEPUBPreferences(EPUBPreferences preferences); diff --git a/flutter_readium_platform_interface/lib/src/shared/publication/link.dart b/flutter_readium_platform_interface/lib/src/shared/publication/link.dart index 0124452b..7614002f 100644 --- a/flutter_readium_platform_interface/lib/src/shared/publication/link.dart +++ b/flutter_readium_platform_interface/lib/src/shared/publication/link.dart @@ -10,11 +10,7 @@ import 'package:fimber/fimber.dart'; import 'package:json_annotation/json_annotation.dart'; import 'package:meta/meta.dart'; -import '../../utils/href.dart'; -import '../../utils/jsonable.dart'; -import '../../utils/uri_template.dart'; -import '../mediatype/mediatype.dart'; -import 'properties.dart'; +import '../../../flutter_readium_platform_interface.dart'; export 'link_list_extension.dart'; @@ -123,11 +119,11 @@ class Link with EquatableMixin implements JSONable { /// given collection role. final List children; - List get _hrefParts => href.split('#'); + (String, String?) get _hrefParts => href.splitPathAndFragment(); - String get hrefPart => _hrefParts[0]; + String get hrefPart => _hrefParts.$1; - String? get elementId => (_hrefParts.length > 1) ? _hrefParts[1] : null; + String? get elementId => _hrefParts.$2; Link copyWith({ String? id, diff --git a/flutter_readium_platform_interface/lib/src/shared/publication/locator.dart b/flutter_readium_platform_interface/lib/src/shared/publication/locator.dart index 7b99d8b9..62fdd5d6 100644 --- a/flutter_readium_platform_interface/lib/src/shared/publication/locator.dart +++ b/flutter_readium_platform_interface/lib/src/shared/publication/locator.dart @@ -4,7 +4,6 @@ import 'dart:convert'; -import 'package:dartx/dartx.dart'; import 'package:dfunc/dfunc.dart'; import 'package:equatable/equatable.dart'; import 'package:fimber/fimber.dart'; @@ -12,6 +11,7 @@ import 'package:json_annotation/json_annotation.dart'; import 'package:meta/meta.dart'; import '../../extensions/readium_string_extensions.dart'; +import '../../extensions/strings.dart'; import '../../utils/additional_properties.dart'; import '../../utils/jsonable.dart'; import '../../utils/take.dart'; @@ -383,11 +383,10 @@ class LocatorText with EquatableMixin implements JSONable { extension LinkLocator on Link { /// Creates a [Locator] from a reading order [Link]. Locator toLocator() { - final components = href.split('#'); - final fragment = (components.length > 1 && components[1].isNotEmpty) ? components[1] : null; + final (hrefPath, fragment) = href.splitPathAndFragment(); return Locator( - href: components.firstOrDefault(href).stripLeadingSlash(), + href: hrefPath, type: type ?? '', title: title, text: LocatorText(), diff --git a/flutter_readium_platform_interface/lib/src/shared/publication/publication.dart b/flutter_readium_platform_interface/lib/src/shared/publication/publication.dart index e3960017..ef964773 100644 --- a/flutter_readium_platform_interface/lib/src/shared/publication/publication.dart +++ b/flutter_readium_platform_interface/lib/src/shared/publication/publication.dart @@ -145,24 +145,21 @@ class Publication with EquatableMixin implements JSONable { } Locator? locatorFromLink(final Link link, {final MediaType? typeOverride}) { - final href = link.href; - final hashIndex = href.indexOf(_hrefEnd); - final hrefHead = hashIndex == -1 ? href : href.substring(0, hashIndex); - final hrefTail = hashIndex == -1 ? null : href.substring(hashIndex + 1); - final resourceLink = linkWithHref(hrefHead); + final (href, fragments) = link.href.splitPathAndFragment(); + final resourceLink = linkWithHref(href); final type = resourceLink?.type ?? typeOverride?.name; final linkIndex = resourceLink == null ? -1 : readingOrder.indexOf(resourceLink); return type == null ? null : Locator( - href: hrefHead.stripLeadingSlash(), + href: href, type: type, title: resourceLink!.title ?? link.title, text: LocatorText(), locations: Locations( - cssSelector: hrefTail != null && hrefTail.isNotEmpty ? '#$hrefTail' : null, - fragments: hrefTail == null ? [] : [hrefTail], - progression: hrefTail == null ? 0 : null, + cssSelector: fragments != null && fragments.isNotEmpty ? '#$fragments' : null, + fragments: fragments == null ? [] : [fragments], + progression: fragments == null ? 0 : null, position: linkIndex == -1 ? null : linkIndex + 1, ), );