@internetarchive/bookreader 5.0.0-12-fixsorting-7 → 5.0.0-120
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/BookReader/474.js +2 -0
- package/BookReader/474.js.map +1 -0
- package/BookReader/BookReader.css +670 -1190
- package/BookReader/bergamot-translator-worker.js +2966 -0
- package/BookReader/bergamot-translator-worker.wasm +0 -0
- package/BookReader/hypothesis/LICENSE +50 -0
- package/BookReader/hypothesis/README.md +55 -0
- package/BookReader/hypothesis/build/boot-template.js +1 -0
- package/BookReader/hypothesis/build/boot.js +1 -0
- package/BookReader/hypothesis/build/manifest.json +20 -0
- package/BookReader/hypothesis/build/scripts/annotator.bundle.js +199 -0
- package/BookReader/hypothesis/build/scripts/annotator.bundle.js.map +1 -0
- package/BookReader/hypothesis/build/scripts/sidebar.bundle.js +811 -0
- package/BookReader/hypothesis/build/scripts/sidebar.bundle.js.map +1 -0
- package/BookReader/hypothesis/build/scripts/ui-playground.bundle.js +437 -0
- package/BookReader/hypothesis/build/scripts/ui-playground.bundle.js.map +1 -0
- package/BookReader/hypothesis/build/styles/annotator.css +4 -0
- package/BookReader/hypothesis/build/styles/annotator.css.map +1 -0
- package/BookReader/hypothesis/build/styles/fonts/KaTeX_AMS-Regular.woff2 +0 -0
- package/BookReader/hypothesis/build/styles/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
- package/BookReader/hypothesis/build/styles/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
- package/BookReader/hypothesis/build/styles/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
- package/BookReader/hypothesis/build/styles/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
- package/BookReader/hypothesis/build/styles/fonts/KaTeX_Main-Bold.woff2 +0 -0
- package/BookReader/hypothesis/build/styles/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
- package/BookReader/hypothesis/build/styles/fonts/KaTeX_Main-Italic.woff2 +0 -0
- package/BookReader/hypothesis/build/styles/fonts/KaTeX_Main-Regular.woff2 +0 -0
- package/BookReader/hypothesis/build/styles/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
- package/BookReader/hypothesis/build/styles/fonts/KaTeX_Math-Italic.woff2 +0 -0
- package/BookReader/hypothesis/build/styles/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
- package/BookReader/hypothesis/build/styles/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
- package/BookReader/hypothesis/build/styles/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
- package/BookReader/hypothesis/build/styles/fonts/KaTeX_Script-Regular.woff2 +0 -0
- package/BookReader/hypothesis/build/styles/fonts/KaTeX_Size1-Regular.woff2 +0 -0
- package/BookReader/hypothesis/build/styles/fonts/KaTeX_Size2-Regular.woff2 +0 -0
- package/BookReader/hypothesis/build/styles/fonts/KaTeX_Size3-Regular.woff2 +0 -0
- package/BookReader/hypothesis/build/styles/fonts/KaTeX_Size4-Regular.woff2 +0 -0
- package/BookReader/hypothesis/build/styles/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
- package/BookReader/hypothesis/build/styles/highlights.css +2 -0
- package/BookReader/hypothesis/build/styles/highlights.css.map +1 -0
- package/BookReader/hypothesis/build/styles/katex.min.css +3 -0
- package/BookReader/hypothesis/build/styles/katex.min.css.map +1 -0
- package/BookReader/hypothesis/build/styles/pdfjs-overrides.css +14 -0
- package/BookReader/hypothesis/build/styles/pdfjs-overrides.css.map +1 -0
- package/BookReader/hypothesis/build/styles/sidebar.css +4 -0
- package/BookReader/hypothesis/build/styles/sidebar.css.map +1 -0
- package/BookReader/hypothesis/build/styles/ui-playground.css +4 -0
- package/BookReader/hypothesis/build/styles/ui-playground.css.map +1 -0
- package/BookReader/hypothesis/package.json +126 -0
- package/BookReader/ia-bookreader.js +1886 -0
- package/BookReader/{BookReader.js.LICENSE.txt → ia-bookreader.js.LICENSE.txt} +26 -20
- package/BookReader/ia-bookreader.js.map +1 -0
- package/BookReader/icons/1up.svg +1 -12
- package/BookReader/icons/2up.svg +1 -15
- package/BookReader/icons/advance.svg +3 -26
- package/BookReader/icons/chevron-right.svg +1 -1
- package/BookReader/icons/close-circle-dark.svg +1 -1
- package/BookReader/icons/close-circle.svg +1 -1
- package/BookReader/icons/fullscreen.svg +1 -17
- package/BookReader/icons/fullscreen_exit.svg +1 -17
- package/BookReader/icons/hamburger.svg +1 -15
- package/BookReader/icons/left-arrow.svg +1 -12
- package/BookReader/icons/magnify-minus.svg +1 -16
- package/BookReader/icons/magnify-plus.svg +1 -17
- package/BookReader/icons/magnify.svg +1 -15
- package/BookReader/icons/pause.svg +1 -23
- package/BookReader/icons/play.svg +1 -22
- package/BookReader/icons/playback-speed.svg +1 -34
- package/BookReader/icons/read-aloud.svg +1 -22
- package/BookReader/icons/review.svg +3 -22
- package/BookReader/icons/slider-toggle.svg +1 -0
- package/BookReader/icons/thumbnails.svg +1 -17
- package/BookReader/icons/voice.svg +1 -0
- package/BookReader/icons/volume-full.svg +1 -22
- package/BookReader/images/BRicons.svg +5 -94
- package/BookReader/images/books_graphic.svg +1 -177
- package/BookReader/images/hypothesis.ico +0 -0
- package/BookReader/images/icon_book.svg +1 -12
- package/BookReader/images/icon_bookmark.svg +1 -12
- package/BookReader/images/icon_experiment.svg +1 -0
- package/BookReader/images/icon_gear.svg +1 -14
- package/BookReader/images/icon_hamburger.svg +1 -20
- package/BookReader/images/icon_home.svg +1 -21
- package/BookReader/images/icon_info.svg +1 -11
- package/BookReader/images/icon_one_page.svg +1 -8
- package/BookReader/images/icon_pause.svg +1 -1
- package/BookReader/images/icon_play.svg +1 -1
- package/BookReader/images/icon_playback-rate.svg +1 -15
- package/BookReader/images/icon_search_button.svg +1 -8
- package/BookReader/images/icon_share.svg +1 -9
- package/BookReader/images/icon_skip-ahead.svg +1 -6
- package/BookReader/images/icon_skip-back.svg +2 -13
- package/BookReader/images/icon_speaker.svg +1 -18
- package/BookReader/images/icon_speaker_open.svg +1 -10
- package/BookReader/images/icon_thumbnails.svg +1 -12
- package/BookReader/images/icon_toc.svg +1 -5
- package/BookReader/images/icon_two_pages.svg +1 -9
- package/BookReader/images/marker_chap-off.svg +1 -11
- package/BookReader/images/marker_chap-on.svg +1 -11
- package/BookReader/images/marker_srch-on.svg +1 -11
- package/BookReader/images/translate.svg +1 -0
- package/BookReader/images/unviewable_page.png +0 -0
- package/BookReader/jquery-3.js +2 -0
- package/BookReader/jquery-3.js.LICENSE.txt +24 -0
- package/BookReader/plugins/plugin.archive_analytics.js +1 -172
- package/BookReader/plugins/plugin.archive_analytics.js.map +1 -1
- package/BookReader/plugins/plugin.autoplay.js +1 -165
- package/BookReader/plugins/plugin.autoplay.js.map +1 -1
- package/BookReader/plugins/plugin.chapters.js +19 -301
- package/BookReader/plugins/plugin.chapters.js.LICENSE.txt +1 -0
- package/BookReader/plugins/plugin.chapters.js.map +1 -1
- package/BookReader/plugins/plugin.experiments.js +3 -0
- package/BookReader/plugins/plugin.experiments.js.LICENSE.txt +1 -0
- package/BookReader/plugins/plugin.experiments.js.map +1 -0
- package/BookReader/plugins/plugin.iframe.js +1 -74
- package/BookReader/plugins/plugin.iframe.js.map +1 -1
- package/BookReader/plugins/plugin.iiif.js +2 -0
- package/BookReader/plugins/plugin.iiif.js.map +1 -0
- package/BookReader/plugins/plugin.resume.js +1 -368
- package/BookReader/plugins/plugin.resume.js.map +1 -1
- package/BookReader/plugins/plugin.search.js +2 -1385
- package/BookReader/plugins/plugin.search.js.LICENSE.txt +1 -0
- package/BookReader/plugins/plugin.search.js.map +1 -1
- package/BookReader/plugins/plugin.text_selection.js +2 -1080
- package/BookReader/plugins/plugin.text_selection.js.LICENSE.txt +1 -0
- package/BookReader/plugins/plugin.text_selection.js.map +1 -1
- package/BookReader/plugins/plugin.translate.js +3 -0
- package/BookReader/plugins/plugin.translate.js.LICENSE.txt +1 -0
- package/BookReader/plugins/plugin.translate.js.map +1 -0
- package/BookReader/plugins/plugin.tts.js +2 -9193
- package/BookReader/plugins/plugin.tts.js.LICENSE.txt +2 -0
- package/BookReader/plugins/plugin.tts.js.map +1 -1
- package/BookReader/plugins/plugin.url.js +1 -269
- package/BookReader/plugins/plugin.url.js.map +1 -1
- package/BookReader/plugins/plugin.vendor-fullscreen.js +1 -379
- package/BookReader/plugins/plugin.vendor-fullscreen.js.map +1 -1
- package/BookReader/plugins/translator-worker.js +2 -0
- package/BookReader/plugins/translator-worker.js.map +1 -0
- package/BookReader/silence.mp3 +0 -0
- package/BookReader/translator-worker.js +475 -0
- package/BookReader/webcomponents-bundle.js +3 -0
- package/BookReader/webcomponents-bundle.js.LICENSE.txt +9 -0
- package/BookReader/webcomponents-bundle.js.map +1 -0
- package/README.md +17 -4
- package/dist/esm/ia-bookreader.js +3 -0
- package/dist/esm/ia-bookreader.js.LICENSE.txt +17 -0
- package/dist/esm/ia-bookreader.js.map +1 -0
- package/dist/esm/plugins/plugin.archive_analytics.js +2 -0
- package/dist/esm/plugins/plugin.archive_analytics.js.map +1 -0
- package/dist/esm/plugins/plugin.autoplay.js +2 -0
- package/dist/esm/plugins/plugin.autoplay.js.map +1 -0
- package/dist/esm/plugins/plugin.chapters.js +2 -0
- package/dist/esm/plugins/plugin.chapters.js.map +1 -0
- package/dist/esm/plugins/plugin.experiments.js +2 -0
- package/dist/esm/plugins/plugin.experiments.js.map +1 -0
- package/dist/esm/plugins/plugin.iframe.js +2 -0
- package/dist/esm/plugins/plugin.iframe.js.map +1 -0
- package/dist/esm/plugins/plugin.iiif.js +2 -0
- package/dist/esm/plugins/plugin.iiif.js.map +1 -0
- package/dist/esm/plugins/plugin.resume.js +2 -0
- package/dist/esm/plugins/plugin.resume.js.map +1 -0
- package/dist/esm/plugins/plugin.search.js +2 -0
- package/dist/esm/plugins/plugin.search.js.map +1 -0
- package/dist/esm/plugins/plugin.text_selection.js +2 -0
- package/dist/esm/plugins/plugin.text_selection.js.map +1 -0
- package/dist/esm/plugins/plugin.translate.js +2 -0
- package/dist/esm/plugins/plugin.translate.js.map +1 -0
- package/dist/esm/plugins/plugin.tts.js +2 -0
- package/dist/esm/plugins/plugin.tts.js.map +1 -0
- package/dist/esm/plugins/plugin.url.js +2 -0
- package/dist/esm/plugins/plugin.url.js.map +1 -0
- package/dist/esm/plugins/plugin.vendor-fullscreen.js +2 -0
- package/dist/esm/plugins/plugin.vendor-fullscreen.js.map +1 -0
- package/jsconfig.json +18 -0
- package/package.json +113 -71
- package/src/BookReader/BookModel.js +92 -46
- package/src/BookReader/DragScrollable.js +233 -0
- package/src/BookReader/ImageCache.js +49 -16
- package/src/BookReader/Mode1Up.js +66 -365
- package/src/BookReader/Mode1UpLit.js +392 -0
- package/src/BookReader/Mode2Up.js +87 -1315
- package/src/BookReader/Mode2UpLit.js +786 -0
- package/src/BookReader/ModeAbstract.js +43 -0
- package/src/BookReader/ModeCoordinateSpace.js +29 -0
- package/src/BookReader/ModeSmoothZoom.js +312 -0
- package/src/BookReader/ModeThumb.js +29 -15
- package/src/BookReader/Navbar/Navbar.js +204 -84
- package/src/BookReader/PageContainer.js +120 -23
- package/src/BookReader/ReduceSet.js +2 -2
- package/src/BookReader/Toolbar/Toolbar.js +18 -40
- package/src/BookReader/events.js +3 -3
- package/src/BookReader/options.js +105 -18
- package/src/BookReader/utils/HTMLDimensionsCacher.js +44 -0
- package/src/BookReader/utils/ScrollClassAdder.js +31 -0
- package/src/BookReader/utils/SelectionObserver.js +72 -0
- package/src/BookReader/utils/classes.js +1 -1
- package/src/BookReader/utils.js +136 -12
- package/src/BookReader.js +732 -1231
- package/src/BookReaderPlugin.js +52 -0
- package/src/assets/icons/magnify-minus.svg +3 -7
- package/src/assets/icons/magnify-plus.svg +3 -7
- package/src/assets/icons/slider-toggle.svg +1 -0
- package/src/assets/icons/voice.svg +1 -0
- package/src/assets/images/hypothesis.ico +0 -0
- package/src/assets/images/icon_experiment.svg +1 -0
- package/src/assets/images/translate.svg +1 -0
- package/src/assets/images/unviewable_page.png +0 -0
- package/src/assets/silence.mp3 +0 -0
- package/src/css/BookReader.scss +1 -5
- package/src/css/_BRBookmarks.scss +1 -1
- package/src/css/_BRComponent.scss +1 -1
- package/src/css/_BRicon.scss +8 -2
- package/src/css/_BRmain.scss +16 -3
- package/src/css/_BRnav.scss +74 -70
- package/src/css/_BRpages.scss +174 -42
- package/src/css/_BRsearch.scss +69 -30
- package/src/css/_BRtoolbar.scss +5 -5
- package/src/css/_TextSelection.scss +249 -25
- package/src/css/_colorbox.scss +2 -2
- package/src/css/_controls.scss +24 -7
- package/src/css/_icons.scss +8 -1
- package/src/{BookNavigator/assets → css}/button-base.js +2 -2
- package/src/css/icon_checkmark.js +9 -0
- package/src/css/sharedStyles.js +15 -0
- package/src/ia-bookreader/downloads/downloads-provider.js +81 -0
- package/src/{BookNavigator → ia-bookreader}/downloads/downloads.js +25 -5
- package/src/ia-bookreader/ia-bookreader.js +686 -0
- package/src/ia-bookreader/sharing.js +27 -0
- package/src/ia-bookreader/viewable-files.js +98 -0
- package/src/{BookNavigator → ia-bookreader}/visual-adjustments/visual-adjustments-provider.js +17 -17
- package/src/{BookNavigator → ia-bookreader}/visual-adjustments/visual-adjustments.js +75 -67
- package/src/{BookNavigator → plugins}/bookmarks/bookmark-button.js +4 -3
- package/src/{BookNavigator/assets → plugins/bookmarks}/bookmark-colors.js +1 -1
- package/src/{BookNavigator → plugins}/bookmarks/bookmark-edit.js +43 -31
- package/src/{BookNavigator → plugins}/bookmarks/bookmarks-list.js +48 -49
- package/src/{BookNavigator → plugins}/bookmarks/bookmarks-loginCTA.js +3 -3
- package/src/plugins/bookmarks/bookmarks-provider.js +63 -0
- package/src/{BookNavigator → plugins/bookmarks}/delete-modal-actions.js +1 -1
- package/src/{BookNavigator → plugins}/bookmarks/ia-bookmarks.js +117 -68
- package/src/plugins/plugin.archive_analytics.js +84 -78
- package/src/plugins/plugin.autoplay.js +99 -104
- package/src/plugins/plugin.chapters.js +323 -205
- package/src/plugins/plugin.experiments.js +375 -0
- package/src/plugins/plugin.iframe.js +36 -37
- package/src/plugins/plugin.iiif.js +147 -0
- package/src/plugins/plugin.resume.js +54 -51
- package/src/plugins/plugin.text_selection.js +464 -219
- package/src/plugins/plugin.vendor-fullscreen.js +5 -5
- package/src/plugins/search/plugin.search.js +347 -397
- package/src/{BookNavigator → plugins}/search/search-provider.js +59 -27
- package/src/{BookNavigator → plugins}/search/search-results.js +105 -76
- package/src/plugins/search/utils.js +50 -0
- package/src/plugins/search/view.js +51 -69
- package/src/plugins/translate/TranslationManager.js +164 -0
- package/src/plugins/translate/plugin.translate.js +559 -0
- package/src/plugins/tts/AbstractTTSEngine.js +78 -49
- package/src/plugins/tts/FestivalTTSEngine.js +20 -30
- package/src/plugins/tts/PageChunk.js +117 -23
- package/src/plugins/tts/PageChunkIterator.js +11 -17
- package/src/plugins/tts/WebTTSEngine.js +132 -92
- package/src/plugins/tts/plugin.tts.js +341 -351
- package/src/plugins/tts/utils.js +76 -67
- package/src/plugins/url/UrlPlugin.js +213 -0
- package/src/plugins/{plugin.url.js → url/plugin.url.js} +54 -18
- package/src/util/TextSelectionManager.js +1464 -0
- package/src/util/browserSniffing.js +42 -1
- package/src/util/cache.js +20 -0
- package/src/util/docCookies.js +21 -2
- package/src/util/dom.js +88 -0
- package/src/util/generators.js +89 -0
- package/src/util/lit.js +15 -0
- package/src/util/strings.js +11 -0
- package/.babelrc +0 -12
- package/.dependabot/config.yml +0 -6
- package/.eslintrc.js +0 -50
- package/.gitattributes +0 -2
- package/.github/ISSUE_TEMPLATE/bug.md +0 -32
- package/.github/ISSUE_TEMPLATE/feature-request.md +0 -30
- package/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md +0 -15
- package/.github/workflows/node.js.yml +0 -37
- package/.github/workflows/npm-publish.yml +0 -47
- package/.testcaferc.json +0 -5
- package/BookReader/BookReader.js +0 -21613
- package/BookReader/BookReader.js.map +0 -1
- package/BookReader/bookreader-component-bundle.js +0 -14497
- package/BookReader/bookreader-component-bundle.js.LICENSE.txt +0 -38
- package/BookReader/bookreader-component-bundle.js.map +0 -1
- package/BookReader/jquery-1.10.1.js +0 -108
- package/BookReader/jquery-1.10.1.js.LICENSE.txt +0 -24
- package/BookReader/plugins/plugin.menu_toggle.js +0 -369
- package/BookReader/plugins/plugin.menu_toggle.js.map +0 -1
- package/BookReader/plugins/plugin.mobile_nav.js +0 -335
- package/BookReader/plugins/plugin.mobile_nav.js.map +0 -1
- package/BookReaderDemo/BookReaderDemo.css +0 -41
- package/BookReaderDemo/BookReaderJSAdvanced.js +0 -115
- package/BookReaderDemo/BookReaderJSAutoplay.js +0 -56
- package/BookReaderDemo/BookReaderJSSimple.js +0 -55
- package/BookReaderDemo/IIIFBookReader.js +0 -207
- package/BookReaderDemo/assets/v5/Bookreader-logo-cool-grad.svg +0 -1
- package/BookReaderDemo/assets/v5/Bookreader-logo-flat.svg +0 -1
- package/BookReaderDemo/assets/v5/Bookreader-logo-hex-cool-grad.png +0 -0
- package/BookReaderDemo/assets/v5/Bookreader-logo-hex-flat.png +0 -0
- package/BookReaderDemo/assets/v5/Bookreader-logo-lines.png +0 -0
- package/BookReaderDemo/assets/v5/Bookreader-logo-lines.svg +0 -1
- package/BookReaderDemo/assets/v5/Bookreader-logo-warm.svg +0 -1
- package/BookReaderDemo/assets/v5/bookreader-logo-renders@1x.png +0 -0
- package/BookReaderDemo/assets/v5/bookreader-logo-renders@2x.png +0 -0
- package/BookReaderDemo/assets/v5/bookreader-v5-screenshot.png +0 -0
- package/BookReaderDemo/bookreader-template-bundle.js +0 -7178
- package/BookReaderDemo/demo-advanced.html +0 -33
- package/BookReaderDemo/demo-autoplay.html +0 -38
- package/BookReaderDemo/demo-embed-iframe-src.html +0 -84
- package/BookReaderDemo/demo-embed.html +0 -26
- package/BookReaderDemo/demo-fullscreen-mobile.html +0 -36
- package/BookReaderDemo/demo-fullscreen.html +0 -33
- package/BookReaderDemo/demo-iiif.html +0 -34
- package/BookReaderDemo/demo-iiif.js +0 -26
- package/BookReaderDemo/demo-internetarchive.html +0 -74
- package/BookReaderDemo/demo-multiple.html +0 -43
- package/BookReaderDemo/demo-plugin-menu-toggle.html +0 -34
- package/BookReaderDemo/demo-preview-pages.html +0 -1092
- package/BookReaderDemo/demo-simple.html +0 -34
- package/BookReaderDemo/demo-vendor-fullscreen.html +0 -36
- package/BookReaderDemo/immersion-1up.html +0 -64
- package/BookReaderDemo/immersion-mode.html +0 -35
- package/BookReaderDemo/toggle_controls.html +0 -53
- package/BookReaderDemo/view_mode.html +0 -39
- package/BookReaderDemo/viewmode-cycle.html +0 -41
- package/CHANGELOG.md +0 -499
- package/CONTRIBUTING.md +0 -7
- package/codecov.yml +0 -17
- package/index.html +0 -31
- package/karma.conf.js +0 -23
- package/screenshot.png +0 -0
- package/scripts/postversion.js +0 -10
- package/scripts/preversion.js +0 -14
- package/scripts/version.js +0 -26
- package/src/BookNavigator/BookModel.js +0 -14
- package/src/BookNavigator/BookNavigator.js +0 -459
- package/src/BookNavigator/assets/book-loader.js +0 -27
- package/src/BookNavigator/assets/icon_checkmark.js +0 -6
- package/src/BookNavigator/assets/icon_close.js +0 -3
- package/src/BookNavigator/assets/icon_sort_ascending.js +0 -5
- package/src/BookNavigator/assets/icon_sort_descending.js +0 -5
- package/src/BookNavigator/assets/icon_sort_neutral.js +0 -5
- package/src/BookNavigator/assets/icon_volumes.js +0 -11
- package/src/BookNavigator/bookmarks/bookmarks-provider.js +0 -53
- package/src/BookNavigator/br-fullscreen-mgr.js +0 -83
- package/src/BookNavigator/downloads/downloads-provider.js +0 -66
- package/src/BookNavigator/search/a-search-result.js +0 -55
- package/src/BookNavigator/volumes/volumes-provider.js +0 -83
- package/src/BookNavigator/volumes/volumes.js +0 -178
- package/src/BookReader/DebugConsole.js +0 -54
- package/src/BookReaderComponent/BookReaderComponent.js +0 -112
- package/src/ItemNavigator/ItemNavigator.js +0 -376
- package/src/ItemNavigator/providers/sharing.js +0 -33
- package/src/css/_MobileNav.scss +0 -194
- package/src/dragscrollable-br.js +0 -261
- package/src/plugins/menu_toggle/plugin.menu_toggle.js +0 -324
- package/src/plugins/plugin.mobile_nav.js +0 -287
- package/tests/BookReader/BookModel.test.js +0 -312
- package/tests/BookReader/BookReaderPublicFunctions.test.js +0 -171
- package/tests/BookReader/DebugConsole.test.js +0 -25
- package/tests/BookReader/ImageCache.test.js +0 -150
- package/tests/BookReader/Mode1Up.test.js +0 -164
- package/tests/BookReader/Mode2Up.test.js +0 -247
- package/tests/BookReader/Navbar/Navbar.test.js +0 -169
- package/tests/BookReader/PageContainer.test.js +0 -115
- package/tests/BookReader/ReduceSet.test.js +0 -38
- package/tests/BookReader/Toolbar/Toolbar.test.js +0 -26
- package/tests/BookReader/utils/classes.test.js +0 -88
- package/tests/BookReader/utils.test.js +0 -109
- package/tests/BookReader.options.test.js +0 -39
- package/tests/BookReader.test.js +0 -301
- package/tests/e2e/README.md +0 -75
- package/tests/e2e/autoplay.test.js +0 -13
- package/tests/e2e/base.test.js +0 -35
- package/tests/e2e/helpers/base.js +0 -263
- package/tests/e2e/helpers/debug.js +0 -13
- package/tests/e2e/helpers/desktopSearch.js +0 -72
- package/tests/e2e/helpers/mobileSearch.js +0 -85
- package/tests/e2e/helpers/mockSearch.js +0 -93
- package/tests/e2e/helpers/rightToLeft.js +0 -29
- package/tests/e2e/ia-production/ia-prod-base.js +0 -17
- package/tests/e2e/models/BookReader.js +0 -11
- package/tests/e2e/models/Navigation.js +0 -56
- package/tests/e2e/rightToLeft.test.js +0 -20
- package/tests/e2e/viewmode.test.js +0 -37
- package/tests/karma/BookNavigator/book-navigator.test.js +0 -139
- package/tests/karma/BookNavigator/bookmarks/bookmark-edit.test.js +0 -133
- package/tests/karma/BookNavigator/bookmarks/bookmarks-list.test.js +0 -222
- package/tests/karma/BookNavigator/search/search-provider.test.js +0 -23
- package/tests/karma/BookNavigator/search/search-results.test.js +0 -240
- package/tests/karma/BookNavigator/sharing/sharing-provider.test.js +0 -40
- package/tests/karma/BookNavigator/visual-adjustments.test.js +0 -201
- package/tests/karma/BookNavigator/volumes/volumes-provider.test.js +0 -141
- package/tests/karma/BookNavigator/volumes/volumes.test.js +0 -98
- package/tests/plugins/menu_toggle/plugin.menu_toggle.test.js +0 -68
- package/tests/plugins/plugin.archive_analytics.test.js +0 -23
- package/tests/plugins/plugin.autoplay.test.js +0 -52
- package/tests/plugins/plugin.chapters.test.js +0 -130
- package/tests/plugins/plugin.iframe.test.js +0 -42
- package/tests/plugins/plugin.mobile_nav.test.js +0 -66
- package/tests/plugins/plugin.resume.test.js +0 -98
- package/tests/plugins/plugin.text_selection.test.js +0 -203
- package/tests/plugins/plugin.url.test.js +0 -129
- package/tests/plugins/plugin.vendor-fullscreen.test.js +0 -65
- package/tests/plugins/search/plugin.search.test.js +0 -173
- package/tests/plugins/search/plugin.search.view.test.js +0 -106
- package/tests/plugins/tts/AbstractTTSEngine.test.js +0 -153
- package/tests/plugins/tts/FestivalTTSEngine.test.js +0 -59
- package/tests/plugins/tts/PageChunk.test.js +0 -57
- package/tests/plugins/tts/PageChunkIterator.test.js +0 -179
- package/tests/plugins/tts/WebTTSEngine.test.js +0 -126
- package/tests/plugins/tts/utils.test.js +0 -133
- package/tests/util/browserSniffing.test.js +0 -56
- package/tests/util/docCookies.test.js +0 -15
- package/tests/util/strings.test.js +0 -63
- package/tests/utils.js +0 -42
- package/webpack.config.js +0 -86
|
@@ -0,0 +1,1464 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { countWords } from './strings.js';
|
|
3
|
+
import { SelectionObserver } from "../BookReader/utils/SelectionObserver.js";
|
|
4
|
+
import { clamp } from "../BookReader/utils.js";
|
|
5
|
+
import { html, LitElement } from 'lit';
|
|
6
|
+
import { customElement, property, query } from 'lit/decorators.js';
|
|
7
|
+
import { ifDefined } from 'lit/directives/if-defined.js';
|
|
8
|
+
import '@internetarchive/icon-share';
|
|
9
|
+
import '@internetarchive/icon-edit-pencil/icon-edit-pencil.js';
|
|
10
|
+
import '@internetarchive/icon-ellipses';
|
|
11
|
+
import { isIOS, isAndroid } from './browserSniffing.js';
|
|
12
|
+
import { genAt, genFilter } from './generators.js';
|
|
13
|
+
|
|
14
|
+
const BR_HIGHLIGHTS_LOCAL_STORAGE_KEY = "BRhighlightStorage";
|
|
15
|
+
const MAX_FULL_QUOTE_URL_CHARS = 80;
|
|
16
|
+
const TRUNCATED_QUOTE_WORD_COUNT = 3;
|
|
17
|
+
|
|
18
|
+
export class TextSelectionManager {
|
|
19
|
+
options = {
|
|
20
|
+
// Current Translation plugin implementation does not have words, will limit to one BRlineElement for now
|
|
21
|
+
maxProtectedWords: 200,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** @type {BRSelectMenu} */
|
|
25
|
+
selectMenu;
|
|
26
|
+
|
|
27
|
+
get selectMenuEnabled() {
|
|
28
|
+
return this.br.plugins.experiments?.isEnabled('copyLinkToHighlight') || this.br.plugins.experiments?.isEnabled('annotateHighlight');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param {string} layer Selector for the text layer to manage
|
|
33
|
+
* @param {import('../BookReader.js').default} br
|
|
34
|
+
* @param {Object} options
|
|
35
|
+
* @param {string[]} options.selectionElement CSS selector for elements that count as "words" for selection limiting
|
|
36
|
+
* @param {number} [maxWords] Maximum number of words allowed to be selected
|
|
37
|
+
*/
|
|
38
|
+
constructor (layer, br, { selectionElement }, maxWords) {
|
|
39
|
+
/** @type {string} */
|
|
40
|
+
this.layer = layer;
|
|
41
|
+
/** @type {import('../BookReader.js').default} */
|
|
42
|
+
this.br = br;
|
|
43
|
+
/** @type {string[]} */
|
|
44
|
+
this.selectionElement = selectionElement;
|
|
45
|
+
this.selectionObserver = new SelectionObserver(this.layer, this._onSelectionChange);
|
|
46
|
+
this.options.maxProtectedWords = maxWords ? maxWords : 200;
|
|
47
|
+
|
|
48
|
+
this.selectMenu = new BRSelectMenu(br);
|
|
49
|
+
this.selectMenu.className = "br-select-menu__root";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
init() {
|
|
53
|
+
this.attach();
|
|
54
|
+
new SelectionObserver(this.layer, (selectEvent) => {
|
|
55
|
+
// Track how often selection is used
|
|
56
|
+
if (selectEvent == 'started') {
|
|
57
|
+
this.br.plugins.archiveAnalytics?.sendEvent('BookReader', 'SelectStart');
|
|
58
|
+
|
|
59
|
+
// Set a class on the page to avoid hiding it when zooming/etc
|
|
60
|
+
this.br.refs.$br.find('.BRpagecontainer--hasSelection').removeClass('BRpagecontainer--hasSelection');
|
|
61
|
+
$(window.getSelection().anchorNode).closest('.BRpagecontainer').addClass('BRpagecontainer--hasSelection');
|
|
62
|
+
// Don't show while still doing selection
|
|
63
|
+
if (!this.mouseIsDown) this.showSelectMenu();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (selectEvent == 'changed') {
|
|
67
|
+
if (!this.mouseIsDown && window.getSelection()?.toString()) {
|
|
68
|
+
this.showSelectMenu();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (selectEvent == 'cleared') {
|
|
73
|
+
this.hideSelectMenu();
|
|
74
|
+
}
|
|
75
|
+
}).attach();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Need attach + detach methods to toggle w/ Translation plugin
|
|
79
|
+
attach() {
|
|
80
|
+
this.selectionObserver.attach();
|
|
81
|
+
|
|
82
|
+
if (this.br.protected) {
|
|
83
|
+
document.addEventListener('selectionchange', this._limitSelection);
|
|
84
|
+
// Prevent right clicking when selected text
|
|
85
|
+
$(document.body).on('contextmenu dragstart copy', (e) => {
|
|
86
|
+
const selection = document.getSelection();
|
|
87
|
+
if (selection?.toString()) {
|
|
88
|
+
const intersectsTextLayer = $(this.layer)
|
|
89
|
+
.toArray()
|
|
90
|
+
.some(el => selection.containsNode(el, true));
|
|
91
|
+
if (intersectsTextLayer) {
|
|
92
|
+
e.preventDefault();
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
detach() {
|
|
101
|
+
this.selectionObserver.detach();
|
|
102
|
+
if (this.br.protected) {
|
|
103
|
+
document.removeEventListener('selectionchange', this._limitSelection);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* @param {'started' | 'cleared' | 'changed'} type
|
|
109
|
+
* @param {HTMLElement} target
|
|
110
|
+
*/
|
|
111
|
+
_onSelectionChange = (type, target) => {
|
|
112
|
+
if (type === 'started') {
|
|
113
|
+
this.textSelectingMode(target);
|
|
114
|
+
} else if (type === 'cleared') {
|
|
115
|
+
this.defaultMode(target);
|
|
116
|
+
} else if (type === 'changed') {
|
|
117
|
+
// do nothing, just wait for the mouseup to trigger the styling change
|
|
118
|
+
} else {
|
|
119
|
+
throw new Error(`Unknown type ${type}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Intercept copied text to remove any styling applied to it
|
|
125
|
+
* @param {JQuery} $container
|
|
126
|
+
*/
|
|
127
|
+
interceptCopy ($container) {
|
|
128
|
+
$container[0].addEventListener('copy', (event) => {
|
|
129
|
+
const selection = document.getSelection();
|
|
130
|
+
event.clipboardData.setData('text/plain', selection.toString());
|
|
131
|
+
event.preventDefault();
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Initializes text selection modes if there is a text layer on the page
|
|
137
|
+
* @param {JQuery} $container
|
|
138
|
+
*/
|
|
139
|
+
stopPageFlip($container) {
|
|
140
|
+
/** @type {JQuery<HTMLElement>} */
|
|
141
|
+
const $textLayer = $container.find(this.layer);
|
|
142
|
+
if (!$textLayer.length) return;
|
|
143
|
+
$textLayer.each((i, s) => this.defaultMode(s));
|
|
144
|
+
if (!this.br.protected) {
|
|
145
|
+
this.interceptCopy($container);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Applies mouse events when in default mode
|
|
151
|
+
* @param {HTMLElement} textLayer
|
|
152
|
+
*/
|
|
153
|
+
defaultMode (textLayer) {
|
|
154
|
+
const $pageContainer = $(textLayer).closest('.BRpagecontainer');
|
|
155
|
+
textLayer.style.pointerEvents = "none";
|
|
156
|
+
$pageContainer.find("img").css("pointer-events", "auto");
|
|
157
|
+
|
|
158
|
+
$(textLayer).off(".textSelectPluginHandler");
|
|
159
|
+
const startedMouseDown = this.mouseIsDown;
|
|
160
|
+
let skipNextMouseup = this.mouseIsDown;
|
|
161
|
+
if (startedMouseDown) {
|
|
162
|
+
textLayer.style.pointerEvents = "auto";
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Need to stop propagation to prevent DragScrollable from
|
|
166
|
+
// blocking selection
|
|
167
|
+
$(textLayer).on("mousedown.textSelectPluginHandler", (event) => {
|
|
168
|
+
this.mouseIsDown = true;
|
|
169
|
+
if ($(event.target).is(this.selectionElement.join(", "))) {
|
|
170
|
+
event.stopPropagation();
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
$(textLayer).on("mouseup.textSelectPluginHandler", (event) => {
|
|
175
|
+
this.mouseIsDown = false;
|
|
176
|
+
textLayer.style.pointerEvents = "none";
|
|
177
|
+
if (skipNextMouseup) {
|
|
178
|
+
skipNextMouseup = false;
|
|
179
|
+
event.stopPropagation();
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* This mode is active while there is a selection on the given textLayer
|
|
186
|
+
* @param {HTMLElement} textLayer
|
|
187
|
+
*/
|
|
188
|
+
textSelectingMode(textLayer) {
|
|
189
|
+
const $pageContainer = $(textLayer).closest('.BRpagecontainer');
|
|
190
|
+
// Make text layer consume all events
|
|
191
|
+
textLayer.style.pointerEvents = "all";
|
|
192
|
+
// Block img from getting long-press to save while selecting
|
|
193
|
+
$pageContainer.find("img").css("pointer-events", "none");
|
|
194
|
+
|
|
195
|
+
$(textLayer).off(".textSelectPluginHandler");
|
|
196
|
+
|
|
197
|
+
$(textLayer).on("mousedown.textSelectPluginHandler", (event) => {
|
|
198
|
+
if (event.which != 1) return;
|
|
199
|
+
this.mouseIsDown = true;
|
|
200
|
+
event.stopPropagation();
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// Prevent page flip on click
|
|
204
|
+
$(textLayer).on('mouseup.textSelectPluginHandler', (event) => {
|
|
205
|
+
this.mouseIsDown = false;
|
|
206
|
+
if (event.which != 1) return;
|
|
207
|
+
event.stopPropagation();
|
|
208
|
+
this.showSelectMenu();
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
showSelectMenu() {
|
|
213
|
+
if (!this.selectMenuEnabled) return;
|
|
214
|
+
|
|
215
|
+
this.selectMenu.copyLinkToHighlightEnabled = this.br.plugins.experiments.isEnabled('copyLinkToHighlight');
|
|
216
|
+
this.selectMenu.highlightAnnotationEnabled = this.br.plugins.experiments.isEnabled('annotateHighlight');
|
|
217
|
+
|
|
218
|
+
if (!this.selectMenu.isConnected) {
|
|
219
|
+
document.body.append(this.selectMenu);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (this.selectMenu.open) {
|
|
223
|
+
this.selectMenu.repositionToSelection();
|
|
224
|
+
} else {
|
|
225
|
+
this.selectMenu.show();
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
hideSelectMenu() {
|
|
230
|
+
this.selectMenu.hide();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
_limitSelection = () => {
|
|
234
|
+
const selection = window.getSelection();
|
|
235
|
+
if (!selection.rangeCount) return;
|
|
236
|
+
|
|
237
|
+
const range = selection.getRangeAt(0);
|
|
238
|
+
|
|
239
|
+
// Check if range.startContainer is inside the sub-tree of .BRContainer
|
|
240
|
+
const startInBr = !!range.startContainer.parentElement.closest('.BRcontainer');
|
|
241
|
+
const endInBr = !!range.endContainer.parentElement.closest('.BRcontainer');
|
|
242
|
+
if (!startInBr && !endInBr) return;
|
|
243
|
+
if (!startInBr || !endInBr) {
|
|
244
|
+
// weird case, just clear the selection
|
|
245
|
+
selection.removeAllRanges();
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Find the last allowed word in the selection
|
|
250
|
+
const lastAllowedWord = genAt(
|
|
251
|
+
genFilter(
|
|
252
|
+
treeWalkRange(range, 'element'),
|
|
253
|
+
(node) => node.classList.contains(
|
|
254
|
+
this.selectionElement[0].replace(".", ""),
|
|
255
|
+
),
|
|
256
|
+
),
|
|
257
|
+
this.options.maxProtectedWords - 1,
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
if (!lastAllowedWord || range.endContainer.parentNode == lastAllowedWord) return;
|
|
261
|
+
|
|
262
|
+
const newRange = document.createRange();
|
|
263
|
+
newRange.setStart(range.startContainer, range.startOffset);
|
|
264
|
+
newRange.setEnd(lastAllowedWord.firstChild, lastAllowedWord.textContent.length);
|
|
265
|
+
|
|
266
|
+
selection.removeAllRanges();
|
|
267
|
+
selection.addRange(newRange);
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* @overload
|
|
273
|
+
* @param {Range} range
|
|
274
|
+
* @param {'text'} filter
|
|
275
|
+
* @returns {Generator<Text>}
|
|
276
|
+
*/
|
|
277
|
+
/**
|
|
278
|
+
* @overload
|
|
279
|
+
* @param {Range} range
|
|
280
|
+
* @param {'element'} filter
|
|
281
|
+
* @returns {Generator<Element>}
|
|
282
|
+
*/
|
|
283
|
+
/**
|
|
284
|
+
* @overload
|
|
285
|
+
* @param {Range} range
|
|
286
|
+
* @param {'text+element'} [filter]
|
|
287
|
+
* @returns {Generator<Text | Element>}
|
|
288
|
+
*/
|
|
289
|
+
/**
|
|
290
|
+
* Depth traverse the DOM tree starting at `start`, and ending at `end`.
|
|
291
|
+
* @param {Range} range
|
|
292
|
+
* @param {'text' | 'element' | 'text+element'} [filter]
|
|
293
|
+
* @returns {Generator<Node>}
|
|
294
|
+
*/
|
|
295
|
+
export function* treeWalkRange(range, filter = 'text+element') {
|
|
296
|
+
const start = range.startContainer;
|
|
297
|
+
const end = range.endContainer;
|
|
298
|
+
let done = false;
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* @param {Node} node
|
|
302
|
+
*/
|
|
303
|
+
const passesFilter = node => {
|
|
304
|
+
return (
|
|
305
|
+
(filter === 'text' && node.nodeType === Node.TEXT_NODE) ||
|
|
306
|
+
(filter === 'element' && node.nodeType === Node.ELEMENT_NODE) ||
|
|
307
|
+
(filter === 'text+element')
|
|
308
|
+
);
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* @param {Node} node
|
|
313
|
+
* @returns {Generator<Node>}
|
|
314
|
+
*/
|
|
315
|
+
function* walk(node, {children = true, parents = true, siblings = true} = {}) {
|
|
316
|
+
if (node === end) {
|
|
317
|
+
done = true;
|
|
318
|
+
if (passesFilter(node)) yield node;
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// yield self
|
|
323
|
+
if (passesFilter(node)) yield node;
|
|
324
|
+
|
|
325
|
+
// First iterate children (depth-first traversal)
|
|
326
|
+
if (children && node.firstChild) {
|
|
327
|
+
yield* walk(node.firstChild, {children: true, parents: false, siblings: true});
|
|
328
|
+
if (done) return;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Then iterate siblings
|
|
332
|
+
if (siblings) {
|
|
333
|
+
for (let sib = node.nextSibling; sib; sib = sib.nextSibling) {
|
|
334
|
+
yield* walk(sib, {children: true, parents: false, siblings: false});
|
|
335
|
+
if (done) return;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Finally, move up the tree
|
|
340
|
+
if (parents && node.parentNode) {
|
|
341
|
+
yield* walk(node.parentNode, {children: false, parents: true, siblings: true});
|
|
342
|
+
if (done) return;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
yield* walk(start);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
@customElement('br-menu-option')
|
|
350
|
+
export class BRSelectMenuOption extends LitElement {
|
|
351
|
+
@property({type: String})
|
|
352
|
+
icon = '';
|
|
353
|
+
|
|
354
|
+
@property({type: String})
|
|
355
|
+
label = '';
|
|
356
|
+
|
|
357
|
+
/** @type {string | null} */
|
|
358
|
+
temporaryText = null;
|
|
359
|
+
|
|
360
|
+
@property({type: Number, attribute: 'temporary-text-duration'})
|
|
361
|
+
temporaryTextDuration = 1200;
|
|
362
|
+
|
|
363
|
+
@property({type: String, attribute: 'aria-label'})
|
|
364
|
+
ariaLabel = '';
|
|
365
|
+
|
|
366
|
+
@property({type: Boolean, attribute: 'live-label'})
|
|
367
|
+
liveLabel = false;
|
|
368
|
+
|
|
369
|
+
@property({type: Boolean})
|
|
370
|
+
temporaryTextVisible = false;
|
|
371
|
+
|
|
372
|
+
/** @type {number | null} */
|
|
373
|
+
temporaryTextTimeoutId = null;
|
|
374
|
+
|
|
375
|
+
/** @override */
|
|
376
|
+
createRenderRoot() {
|
|
377
|
+
// Keep styles from existing stylesheet by opting out of shadow DOM
|
|
378
|
+
return this;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** @override */
|
|
382
|
+
disconnectedCallback() {
|
|
383
|
+
if (this.temporaryTextTimeoutId != null) {
|
|
384
|
+
window.clearTimeout(this.temporaryTextTimeoutId);
|
|
385
|
+
this.temporaryTextTimeoutId = null;
|
|
386
|
+
}
|
|
387
|
+
super.disconnectedCallback();
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* @param {string} text
|
|
392
|
+
*/
|
|
393
|
+
showTemporaryText(text) {
|
|
394
|
+
if (!text?.trim()) return;
|
|
395
|
+
|
|
396
|
+
this.temporaryText = text;
|
|
397
|
+
|
|
398
|
+
this.temporaryTextVisible = true;
|
|
399
|
+
if (this.temporaryTextTimeoutId != null) {
|
|
400
|
+
window.clearTimeout(this.temporaryTextTimeoutId);
|
|
401
|
+
}
|
|
402
|
+
this.temporaryTextTimeoutId = window.setTimeout(() => {
|
|
403
|
+
this.temporaryTextVisible = false;
|
|
404
|
+
this.temporaryText = null;
|
|
405
|
+
this.temporaryTextTimeoutId = null;
|
|
406
|
+
}, this.temporaryTextDuration);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
renderIcon() {
|
|
410
|
+
if (this.icon === 'share') {
|
|
411
|
+
return html`<ia-icon-share class="br-select-menu__icon" aria-hidden="true"></ia-icon-share>`;
|
|
412
|
+
}
|
|
413
|
+
if (this.icon === 'edit-pencil') {
|
|
414
|
+
return html`<ia-icon-edit-pencil class="br-select-menu__icon" aria-hidden="true"></ia-icon-edit-pencil>`;
|
|
415
|
+
}
|
|
416
|
+
if (this.icon === 'ellipses') {
|
|
417
|
+
return html`<ia-icon-ellipses class="br-select-menu__icon" aria-hidden="true"></ia-icon-ellipses>`;
|
|
418
|
+
}
|
|
419
|
+
return '';
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
render() {
|
|
423
|
+
const baseLabel = this.label ?? '';
|
|
424
|
+
const hasTemporaryText = !!this.temporaryText;
|
|
425
|
+
const temporaryLabel = hasTemporaryText ? this.temporaryText : baseLabel;
|
|
426
|
+
const showTemporaryLabel = hasTemporaryText && this.temporaryTextVisible;
|
|
427
|
+
const visibleLabel = showTemporaryLabel ? temporaryLabel : baseLabel;
|
|
428
|
+
const providedAriaLabel = this.ariaLabel?.trim();
|
|
429
|
+
const accessibleLabel = providedAriaLabel && providedAriaLabel !== visibleLabel.trim() ? providedAriaLabel : undefined;
|
|
430
|
+
const sizingLabel = baseLabel.length >= temporaryLabel.length ? baseLabel : temporaryLabel;
|
|
431
|
+
|
|
432
|
+
return html`
|
|
433
|
+
<button
|
|
434
|
+
type="button"
|
|
435
|
+
class="br-select-menu__option"
|
|
436
|
+
role="menuitem"
|
|
437
|
+
aria-label=${ifDefined(accessibleLabel)}
|
|
438
|
+
>
|
|
439
|
+
${this.renderIcon()}
|
|
440
|
+
${
|
|
441
|
+
!hasTemporaryText && !baseLabel ? '' :
|
|
442
|
+
hasTemporaryText ? html`
|
|
443
|
+
<span class="br-select-menu__label-wrap" style="display: inline-flex; position: relative; align-items: center;">
|
|
444
|
+
<span
|
|
445
|
+
class="br-select-menu__label"
|
|
446
|
+
style="visibility: hidden;"
|
|
447
|
+
aria-hidden="true"
|
|
448
|
+
>${sizingLabel}</span>
|
|
449
|
+
<span
|
|
450
|
+
class="br-select-menu__label"
|
|
451
|
+
style="position: absolute; inset: 0; display: inline-flex; align-items: center;"
|
|
452
|
+
aria-live=${this.liveLabel ? 'polite' : 'off'}
|
|
453
|
+
>${showTemporaryLabel ? temporaryLabel : baseLabel}</span>
|
|
454
|
+
</span>
|
|
455
|
+
` : html`
|
|
456
|
+
<span class="br-select-menu__label" aria-live=${this.liveLabel ? 'polite' : 'off'}>${baseLabel}</span>
|
|
457
|
+
`}
|
|
458
|
+
</button>
|
|
459
|
+
`;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
@customElement('br-select-menu')
|
|
464
|
+
class BRSelectMenu extends LitElement {
|
|
465
|
+
/** @type {import('../BookReader.js').default} */
|
|
466
|
+
br;
|
|
467
|
+
|
|
468
|
+
/** @type {boolean} */
|
|
469
|
+
@property({type: Boolean, reflect: true})
|
|
470
|
+
showExtended = false;
|
|
471
|
+
|
|
472
|
+
/** @type {BRSelectMenuOption | null} */
|
|
473
|
+
@query('#br-select-copy-link-option')
|
|
474
|
+
copyLinkOption;
|
|
475
|
+
|
|
476
|
+
@property({type: Boolean, reflect: true})
|
|
477
|
+
open = false;
|
|
478
|
+
|
|
479
|
+
@property({type: Boolean})
|
|
480
|
+
copyLinkToHighlightEnabled = true;
|
|
481
|
+
|
|
482
|
+
@property({type: Boolean})
|
|
483
|
+
highlightAnnotationEnabled = true;
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* @param {import('../BookReader.js').default} br
|
|
487
|
+
*/
|
|
488
|
+
constructor(br) {
|
|
489
|
+
super();
|
|
490
|
+
this.br = br;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** @override */
|
|
494
|
+
createRenderRoot() {
|
|
495
|
+
// Disable shadow DOM; that would require a huge rejiggering of CSS
|
|
496
|
+
return this;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/** @type {number | null} */
|
|
500
|
+
_scrollTimeoutId = null;
|
|
501
|
+
|
|
502
|
+
_onScroll = () => {
|
|
503
|
+
this.classList.add('br-select-menu__root--scrolling');
|
|
504
|
+
if (this._scrollTimeoutId != null) clearTimeout(this._scrollTimeoutId);
|
|
505
|
+
this._scrollTimeoutId = window.setTimeout(() => {
|
|
506
|
+
this._scrollTimeoutId = null;
|
|
507
|
+
this.repositionToSelection();
|
|
508
|
+
this.classList.remove('br-select-menu__root--scrolling');
|
|
509
|
+
}, 150);
|
|
510
|
+
};
|
|
511
|
+
|
|
512
|
+
/** @override */
|
|
513
|
+
connectedCallback() {
|
|
514
|
+
super.connectedCallback();
|
|
515
|
+
this.setAttribute('role', 'menu');
|
|
516
|
+
this.setAttribute('aria-label', 'Text selection actions');
|
|
517
|
+
this.setAttribute('aria-orientation', 'vertical');
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/** @override */
|
|
521
|
+
disconnectedCallback() {
|
|
522
|
+
super.disconnectedCallback();
|
|
523
|
+
window.removeEventListener('scroll', this._onScroll, { capture: true });
|
|
524
|
+
if (this._scrollTimeoutId != null) {
|
|
525
|
+
clearTimeout(this._scrollTimeoutId);
|
|
526
|
+
this._scrollTimeoutId = null;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
renderCopyLinkToHighlightOption() {
|
|
531
|
+
// Mousedown needed to prevent selection from being cleared on iOS
|
|
532
|
+
return html`
|
|
533
|
+
<br-menu-option
|
|
534
|
+
id="br-select-copy-link-option"
|
|
535
|
+
@mousedown=${/** @param {MouseEvent} e */ (e) => e.preventDefault()}
|
|
536
|
+
@click=${this.handleCopyLinkToHighlight}
|
|
537
|
+
icon="share"
|
|
538
|
+
label="Copy Link to Highlight"
|
|
539
|
+
live-label
|
|
540
|
+
></br-menu-option>
|
|
541
|
+
`;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
renderRemoveOption() {
|
|
545
|
+
return html`
|
|
546
|
+
<br-menu-option
|
|
547
|
+
@click=${this.handleDeleteHighlight}
|
|
548
|
+
icon="share"
|
|
549
|
+
label="Delete Highlight and Annotation"
|
|
550
|
+
></br-menu-option>
|
|
551
|
+
`;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
renderAddAnnotationOption() {
|
|
555
|
+
return html`
|
|
556
|
+
<br-menu-option
|
|
557
|
+
@click=${this.handleAddAnnotation}
|
|
558
|
+
icon="edit-pencil"
|
|
559
|
+
label="Annotate"
|
|
560
|
+
></br-menu-option>
|
|
561
|
+
`;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
renderHighlightOption() {
|
|
565
|
+
return html`
|
|
566
|
+
<br-menu-option
|
|
567
|
+
@click=${this.handleHighlightSave}
|
|
568
|
+
icon="edit-pencil"
|
|
569
|
+
label="Highlight Selection"
|
|
570
|
+
></br-menu-option>
|
|
571
|
+
`;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
renderShowMoreOption() {
|
|
575
|
+
return html`
|
|
576
|
+
<br-menu-option
|
|
577
|
+
id="br-select-more"
|
|
578
|
+
@mousedown=${/** @param {MouseEvent} e */ (e) => e.preventDefault()}
|
|
579
|
+
@click=${this.toggleExtendedMenu}
|
|
580
|
+
icon="ellipses"
|
|
581
|
+
aria-label="Show more options"
|
|
582
|
+
></br-menu-option>
|
|
583
|
+
`;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
renderLocalStorageOptions() {
|
|
587
|
+
return html`
|
|
588
|
+
<br-menu-option
|
|
589
|
+
@click=${this.renderSavedHighlights}
|
|
590
|
+
icon="share"
|
|
591
|
+
label="Load Highlights"
|
|
592
|
+
></br-menu-option>
|
|
593
|
+
<br-menu-option
|
|
594
|
+
@click=${() => {window.localStorage.removeItem(BR_HIGHLIGHTS_LOCAL_STORAGE_KEY);}}
|
|
595
|
+
icon="share"
|
|
596
|
+
label="Remove Stored Highlights"
|
|
597
|
+
></br-menu-option>`;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
renderDefaultOptions() {
|
|
601
|
+
return html`
|
|
602
|
+
${this.copyLinkToHighlightEnabled ? this.renderCopyLinkToHighlightOption() : ''}
|
|
603
|
+
${this.highlightAnnotationEnabled && !this.nodesForRemoval ? this.renderHighlightOption() : ''}
|
|
604
|
+
${this.nodesForRemoval ? this.renderRemoveOption() : ''}
|
|
605
|
+
`;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
renderExtendedOptions() {
|
|
609
|
+
return html`
|
|
610
|
+
${this.renderDefaultOptions()}
|
|
611
|
+
${this.renderLocalStorageOptions()}
|
|
612
|
+
`;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
render() {
|
|
616
|
+
const hasMoreOptions = this.br.plugins.experiments?.isEnabled('annotateHighlight');
|
|
617
|
+
// TODO change the second button to use a different icon
|
|
618
|
+
return html`
|
|
619
|
+
${this.showExtended ? this.renderExtendedOptions() : this.renderDefaultOptions()}
|
|
620
|
+
${!this.showExtended && hasMoreOptions ? this.renderShowMoreOption() : ""}
|
|
621
|
+
`;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* @param {MouseEvent} e
|
|
626
|
+
*/
|
|
627
|
+
async handleCopyLinkToHighlight(e) {
|
|
628
|
+
e.preventDefault();
|
|
629
|
+
const currentSelection = /** @type {Selection} */ (window.getSelection());
|
|
630
|
+
const range = currentSelection.getRangeAt(0);
|
|
631
|
+
const textLayer = this.getNodeTextLayer(range.startContainer);
|
|
632
|
+
if (!textLayer) {
|
|
633
|
+
console.warn("No text layer found for selection");
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
const textFragment = BookReaderTextFragment.fromSelection(currentSelection, [textLayer]);
|
|
637
|
+
const linkToHighlight = textFragment.toUrl();
|
|
638
|
+
|
|
639
|
+
await navigator.clipboard.writeText(linkToHighlight);
|
|
640
|
+
this.copyLinkOption?.showTemporaryText('Copied!');
|
|
641
|
+
this.br.trigger('copyLinkToHighlight', { textFragment, link: linkToHighlight });
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* @param {MouseEvent} e
|
|
646
|
+
*/
|
|
647
|
+
toggleExtendedMenu(e) {
|
|
648
|
+
e.preventDefault();
|
|
649
|
+
this.showExtended = !this.showExtended;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* Returns the closest BRtextLayer element on the page that contains the target node
|
|
654
|
+
* @param {Node} node
|
|
655
|
+
* @returns {Element | null}
|
|
656
|
+
*/
|
|
657
|
+
getNodeTextLayer(node) {
|
|
658
|
+
if (!node) return null;
|
|
659
|
+
const element = node instanceof Element ? node : node.parentElement;
|
|
660
|
+
return element?.closest('.BRtextLayer') ?? null;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* Retrieves the current selected text on the page and serializes the quote contents + context
|
|
665
|
+
* The selection is also changed in the DOM to highlight the words
|
|
666
|
+
*/
|
|
667
|
+
handleHighlightSave() {
|
|
668
|
+
const currentSelection = window.getSelection();
|
|
669
|
+
const start = currentSelection.direction === 'backward' ? currentSelection.focusNode.parentElement : currentSelection.anchorNode.parentElement;
|
|
670
|
+
const textLayer = this.getNodeTextLayer(start);
|
|
671
|
+
const highlight = BookReaderTextFragment.fromSelection(currentSelection, [textLayer.parentElement]);
|
|
672
|
+
highlight.uuid = `id-${crypto.randomUUID().split("-")[4]}`;
|
|
673
|
+
const highlights = this.loadHighlightsFromLocalStorage();
|
|
674
|
+
highlights.push(highlight);
|
|
675
|
+
this.saveToLocalStorage(highlights);
|
|
676
|
+
this.renderSavedHighlights();
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
handleAddAnnotation(e) {
|
|
680
|
+
// TODO
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
handleDeleteHighlight() {
|
|
684
|
+
if (this.nodesForRemoval) {
|
|
685
|
+
const uuid = retrieveUUID(this.nodesForRemoval[0]);
|
|
686
|
+
for (const ele of this.nodesForRemoval) {
|
|
687
|
+
const tempText = ele.textContent;
|
|
688
|
+
const parent = ele.parentElement;
|
|
689
|
+
if (parent.classList.contains('BRwordElement') || parent.classList.contains('BRspace')) {
|
|
690
|
+
ele.remove();
|
|
691
|
+
parent.textContent = tempText;
|
|
692
|
+
} else {
|
|
693
|
+
console.log("This element did not match removal criteria:", parent, ele);
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
this.deleteHighlight(uuid);
|
|
697
|
+
this.clearNodesForRemoval();
|
|
698
|
+
} else {
|
|
699
|
+
console.log("there is nothing to remove");
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* @param {string} uuid
|
|
705
|
+
*/
|
|
706
|
+
deleteHighlight(uuid) {
|
|
707
|
+
const highlights = this.loadHighlightsFromLocalStorage();
|
|
708
|
+
for (let idx = 0; idx < highlights.length; idx++) {
|
|
709
|
+
if (highlights[idx].uuid === uuid) {
|
|
710
|
+
highlights.splice(idx, 1);
|
|
711
|
+
this.saveToLocalStorage(highlights);
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* Saves all the highlights in an array to localStorage
|
|
719
|
+
* @param {BookReaderSavedHighlight[]} highlights
|
|
720
|
+
*/
|
|
721
|
+
saveToLocalStorage(highlights) {
|
|
722
|
+
window.localStorage.setItem(BR_HIGHLIGHTS_LOCAL_STORAGE_KEY, JSON.stringify(highlights.map(hl => hl.toJSON())));
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* @returns {BookReaderSavedHighlight[]}
|
|
727
|
+
*/
|
|
728
|
+
loadHighlightsFromLocalStorage() {
|
|
729
|
+
return JSON.parse(window.localStorage.getItem(BR_HIGHLIGHTS_LOCAL_STORAGE_KEY) || "[]")
|
|
730
|
+
.map(item => BookReaderTextFragment.fromJSON(item));
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
renderSavedHighlights() {
|
|
734
|
+
for (const hl of this.loadHighlightsFromLocalStorage()) {
|
|
735
|
+
const textLayer = /** @type {HTMLElement} */ (this.br.$(`.pagediv${hl.pageIndex} .BRtextLayer`)[0]);
|
|
736
|
+
if (!textLayer) continue;
|
|
737
|
+
renderHighlight(textLayer, hl);
|
|
738
|
+
// Attach click behaviour here? Only need one handler per text layer
|
|
739
|
+
$(textLayer)
|
|
740
|
+
.off('mouseup.BRHighlightClick')
|
|
741
|
+
.on('mouseup.BRHighlightClick', (e) => {
|
|
742
|
+
if (!e.target.classList.contains("BRhighlight")) return;
|
|
743
|
+
e.stopPropagation();
|
|
744
|
+
this.handleHighlightClick(e.target);
|
|
745
|
+
});
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/**
|
|
750
|
+
* @param {HTMLElement} target
|
|
751
|
+
*/
|
|
752
|
+
handleHighlightClick(target) {
|
|
753
|
+
const textLayer = this.getNodeTextLayer(target);
|
|
754
|
+
const identifier = retrieveUUID(target);
|
|
755
|
+
const selectedQuoteNodes = textLayer.querySelectorAll(`.${identifier}`);
|
|
756
|
+
|
|
757
|
+
const firstNode = selectedQuoteNodes[0];
|
|
758
|
+
const lastNode = selectedQuoteNodes[selectedQuoteNodes.length - 1];
|
|
759
|
+
|
|
760
|
+
const highlightRange = document.createRange();
|
|
761
|
+
highlightRange.setStart(firstNode, 0);
|
|
762
|
+
highlightRange.setEnd(lastNode, 1);
|
|
763
|
+
|
|
764
|
+
const currentSelection = window.getSelection();
|
|
765
|
+
currentSelection.removeAllRanges();
|
|
766
|
+
currentSelection.addRange(highlightRange);
|
|
767
|
+
this.show();
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
repositionToSelection() {
|
|
771
|
+
const SCREEN_MARGIN = 10;
|
|
772
|
+
const currentSelection = /** @type {Selection} */ (window.getSelection());
|
|
773
|
+
const range = currentSelection.getRangeAt(0);
|
|
774
|
+
const selectionRect = range.getBoundingClientRect();
|
|
775
|
+
|
|
776
|
+
const isMobile = isIOS() || isAndroid();
|
|
777
|
+
// Leave room for mobile teardrop selection handles
|
|
778
|
+
const SELECTION_PADDING = isAndroid() ? 25 : isIOS() ? 15 : 10;
|
|
779
|
+
// On mobile, avoid the native OS text-selection bar which appears near the top of the screen
|
|
780
|
+
const OS_BAR_H = isMobile ? 60 : 0;
|
|
781
|
+
const idealTop = (selectionRect.top < OS_BAR_H
|
|
782
|
+
? selectionRect.bottom + OS_BAR_H
|
|
783
|
+
: selectionRect.bottom) + SELECTION_PADDING;
|
|
784
|
+
const top = Math.max(SCREEN_MARGIN, idealTop) + window.scrollY;
|
|
785
|
+
|
|
786
|
+
const menuWidth = this.getBoundingClientRect().width;
|
|
787
|
+
// Excludes scrollbars
|
|
788
|
+
const windowWidth = document.documentElement.clientWidth;
|
|
789
|
+
const idealLeft = menuWidth > selectionRect.width
|
|
790
|
+
? selectionRect.left + selectionRect.width / 2 - menuWidth / 2
|
|
791
|
+
: selectionRect.left;
|
|
792
|
+
const left = clamp(idealLeft, SCREEN_MARGIN, windowWidth - menuWidth - SCREEN_MARGIN) + window.scrollX;
|
|
793
|
+
|
|
794
|
+
this.style.top = `${top}px`;
|
|
795
|
+
this.style.left = `${left}px`;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// Will always show the simplified menu when rendered after hiding
|
|
799
|
+
async show() {
|
|
800
|
+
if (this.br.plugins.translate?.userToggleTranslate) return;
|
|
801
|
+
|
|
802
|
+
this.style.zIndex = '1';
|
|
803
|
+
this.style.position = 'absolute';
|
|
804
|
+
this.open = true;
|
|
805
|
+
this.classList.remove('br-select-menu__root--scrolling');
|
|
806
|
+
window.removeEventListener('scroll', this._onScroll, { capture: true });
|
|
807
|
+
window.addEventListener('scroll', this._onScroll, { capture: true, passive: true });
|
|
808
|
+
await this.updateComplete; // ensure Lit has rendered so getBoundingClientRect() returns real width
|
|
809
|
+
this.repositionToSelection();
|
|
810
|
+
this.clearNodesForRemoval();
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
hide() {
|
|
814
|
+
if (!this.open) return;
|
|
815
|
+
this.showExtended = false;
|
|
816
|
+
this.open = false;
|
|
817
|
+
window.removeEventListener('scroll', this._onScroll, { capture: true });
|
|
818
|
+
this.clearNodesForRemoval();
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
/**
|
|
823
|
+
* Remove temporary storage for the currently selected highlight and updates selection menu options
|
|
824
|
+
*/
|
|
825
|
+
clearNodesForRemoval = () => {
|
|
826
|
+
this.nodesForRemoval = null;
|
|
827
|
+
this.requestUpdate();
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
/**
|
|
832
|
+
* @param {number} numWords
|
|
833
|
+
* @param {string} text
|
|
834
|
+
* @return {string}
|
|
835
|
+
*/
|
|
836
|
+
export function getFirstWords(numWords, text) {
|
|
837
|
+
text = text.trim();
|
|
838
|
+
const re = new RegExp(String.raw`^(\S+(\s+|$)){1,${numWords}}`);
|
|
839
|
+
const m = text.match(re);
|
|
840
|
+
return m ? m[0].trim() : "";
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
/**
|
|
844
|
+
* @param {number} numWords
|
|
845
|
+
* @param {string} text
|
|
846
|
+
* @return {string}
|
|
847
|
+
*/
|
|
848
|
+
export function getLastWords(numWords, text) {
|
|
849
|
+
text = text.trim();
|
|
850
|
+
const re = new RegExp(String.raw`((^|\s+)\S+){1,${numWords}}\s*?$`);
|
|
851
|
+
const m = text.match(re);
|
|
852
|
+
return m ? m[0].trim() : "";
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
/**
|
|
856
|
+
* @param {Node} parent
|
|
857
|
+
* @returns {Node}
|
|
858
|
+
*/
|
|
859
|
+
export function getFirstMostNode(parent) {
|
|
860
|
+
while (parent.firstChild) {
|
|
861
|
+
parent = parent.firstChild;
|
|
862
|
+
}
|
|
863
|
+
return parent;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/**
|
|
867
|
+
* @param {Node} parent
|
|
868
|
+
* @returns {Node}
|
|
869
|
+
*/
|
|
870
|
+
export function getLastMostNode(parent) {
|
|
871
|
+
while (parent.lastChild) {
|
|
872
|
+
parent = parent.lastChild;
|
|
873
|
+
}
|
|
874
|
+
return parent;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
/**
|
|
878
|
+
* Normalizes text fragment whitespace and removes special delimiter characters
|
|
879
|
+
* to allow for reliable url encoding/decoding
|
|
880
|
+
*
|
|
881
|
+
* Note: It's very important this *does not* change the length of the string to
|
|
882
|
+
* ensure matching works smoothly later.
|
|
883
|
+
*
|
|
884
|
+
* @param {String} string
|
|
885
|
+
*/
|
|
886
|
+
export function replaceTextFragmentDelimiters(string) {
|
|
887
|
+
return string.replace(/(:|-,|,-|,|\n)/g, s => s.length === 1 ? ' ' : ' ');
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
/**
|
|
891
|
+
* @param {String} s
|
|
892
|
+
*/
|
|
893
|
+
export function collapseWhitespace(s) {
|
|
894
|
+
return s.replace(/\s+/g, ' ').trim();
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
/**
|
|
898
|
+
* Finds all regex matches within a range and returns their ranges.
|
|
899
|
+
* @param {RegExp} regex
|
|
900
|
+
* @param {Range} range - the range to search in
|
|
901
|
+
* @param {Node[]} textNodes - visible text nodes within the range
|
|
902
|
+
* @param {{ normalize?: function(string): string }} [options]
|
|
903
|
+
* @returns {Range[] | undefined} Range for each match, or undefined if no matches are found
|
|
904
|
+
*/
|
|
905
|
+
export function findRangeForRegExp(regex, range, textNodes, { normalize = (s) => s } = {}) {
|
|
906
|
+
if (!textNodes.length) return undefined;
|
|
907
|
+
|
|
908
|
+
const startOffset = textNodes[0] === range.startContainer ?
|
|
909
|
+
range.startOffset :
|
|
910
|
+
0;
|
|
911
|
+
const normalizedWholePageString = normalize(textLayerRangeToString(range));
|
|
912
|
+
const normalizedStartOffset = normalize(textNodes[0].textContent.slice(0, startOffset)).length;
|
|
913
|
+
const matchRegex = new RegExp(regex.source, regex.flags.includes('g') ? regex.flags : `${regex.flags}g`);
|
|
914
|
+
|
|
915
|
+
const matches = Array.from(normalizedWholePageString.matchAll(matchRegex));
|
|
916
|
+
const resultRanges = matches.map((match) => {
|
|
917
|
+
const start = getBoundaryPointAtIndex(
|
|
918
|
+
normalizedStartOffset + match.index,
|
|
919
|
+
textNodes,
|
|
920
|
+
false,
|
|
921
|
+
{ normalize },
|
|
922
|
+
);
|
|
923
|
+
const end = getBoundaryPointAtIndex(
|
|
924
|
+
normalizedStartOffset + match.index + match[0].length,
|
|
925
|
+
textNodes,
|
|
926
|
+
true,
|
|
927
|
+
{ normalize },
|
|
928
|
+
);
|
|
929
|
+
|
|
930
|
+
if (!start || !end) {
|
|
931
|
+
console.warn("Could not find boundary points for regex match, skipping this match", {match, start, end});
|
|
932
|
+
return undefined;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
const foundRange = new Range();
|
|
936
|
+
foundRange.setStart(start.node, start.offset);
|
|
937
|
+
foundRange.setEnd(end.node, end.offset);
|
|
938
|
+
return foundRange;
|
|
939
|
+
});
|
|
940
|
+
|
|
941
|
+
const definedRanges = resultRanges.filter((matchRange) => !!matchRange);
|
|
942
|
+
if (!definedRanges?.length) return undefined;
|
|
943
|
+
return definedRanges;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
/**
|
|
947
|
+
* Maps an index from "normalized-space" (concatenated and normalized text) to a "node-space" index (node + offset).
|
|
948
|
+
* @param {Number} index
|
|
949
|
+
* @param {Node[]} nodes
|
|
950
|
+
* @param {boolean} isEnd
|
|
951
|
+
* @param {{ normalize?: function(string): string }} [options]
|
|
952
|
+
*/
|
|
953
|
+
export function getBoundaryPointAtIndex(index, nodes, isEnd, { normalize = (s) => s } = {}) {
|
|
954
|
+
// Rename to have consistent wording
|
|
955
|
+
const normalizedTargetIndex = index;
|
|
956
|
+
/** Characters in node-space counted so far */
|
|
957
|
+
let normalizedI = 0;
|
|
958
|
+
|
|
959
|
+
for (const node of nodes) {
|
|
960
|
+
const nodeText = node.textContent || '';
|
|
961
|
+
const normalizedText = normalize(nodeText);
|
|
962
|
+
const nodeNormalizedStart = normalizedI;
|
|
963
|
+
const nodeNormalizedEnd = nodeNormalizedStart + normalizedText.length + (isEnd ? 1 : 0);
|
|
964
|
+
|
|
965
|
+
if (nodeNormalizedEnd > normalizedTargetIndex) {
|
|
966
|
+
// We have found the node that contains the normalizedTargetIndex we are looking for!
|
|
967
|
+
const normalizedOffset = normalizedTargetIndex - nodeNormalizedStart;
|
|
968
|
+
|
|
969
|
+
// But the normalized text could be a shorter length than the node text,
|
|
970
|
+
// so we try to continually increase the nodeOffset
|
|
971
|
+
let nodeOffset = Math.min(normalizedOffset, nodeText.length);
|
|
972
|
+
|
|
973
|
+
const normalizedSubstring = isEnd ?
|
|
974
|
+
normalizedText.substring(0, normalizedOffset) :
|
|
975
|
+
normalizedText.substring(normalizedOffset);
|
|
976
|
+
|
|
977
|
+
let nodeSubstring = isEnd ?
|
|
978
|
+
normalize(nodeText.substring(0, normalizedOffset)) :
|
|
979
|
+
normalize(nodeText.substring(normalizedOffset));
|
|
980
|
+
|
|
981
|
+
const direction = (isEnd ? -1 : 1) * (normalizedSubstring.length > nodeSubstring.length ? -1 : 1);
|
|
982
|
+
while (nodeOffset >= 0 && nodeOffset <= nodeText.length) {
|
|
983
|
+
if (nodeSubstring.length === normalizedSubstring.length) {
|
|
984
|
+
return {node, offset: nodeOffset};
|
|
985
|
+
}
|
|
986
|
+
nodeOffset += direction;
|
|
987
|
+
|
|
988
|
+
nodeSubstring = isEnd ?
|
|
989
|
+
normalize(nodeText.substring(0, nodeOffset)) :
|
|
990
|
+
normalize(nodeText.substring(nodeOffset));
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
normalizedI += normalizedText.length;
|
|
994
|
+
}
|
|
995
|
+
return undefined;
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
/**
|
|
999
|
+
* @param {Range} range
|
|
1000
|
+
*/
|
|
1001
|
+
export function textLayerRangeToString(range) {
|
|
1002
|
+
const textNodes = Array.from(genFilter(treeWalkRange(range, 'text'), isBRVisibleTextNode));
|
|
1003
|
+
let result = textNodes.map(node => node.textContent).join('');
|
|
1004
|
+
const firstTextNode = textNodes[0];
|
|
1005
|
+
const lastTextNode = textNodes[textNodes.length - 1];
|
|
1006
|
+
if (range.startContainer === firstTextNode) {
|
|
1007
|
+
result = result.substring(range.startOffset);
|
|
1008
|
+
}
|
|
1009
|
+
if (range.endContainer === lastTextNode) {
|
|
1010
|
+
result = result.substring(0, result.length - (lastTextNode.textContent.length - range.endOffset));
|
|
1011
|
+
}
|
|
1012
|
+
return result;
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
/**
|
|
1016
|
+
* Takes a text quote object and a container element, and wraps the quote
|
|
1017
|
+
* within the container element in a span to apply highlight-like styling
|
|
1018
|
+
*
|
|
1019
|
+
* Iterate through the range and determine the "start" and "end" nodes
|
|
1020
|
+
* Example of how this is done via polyfill
|
|
1021
|
+
* https://github.com/GoogleChromeLabs/text-fragments-polyfill/blob/main/src/text-fragment-utils.js#L743
|
|
1022
|
+
*
|
|
1023
|
+
* @param {HTMLElement} textLayer
|
|
1024
|
+
* @param {BookReaderTextFragment} textFragment
|
|
1025
|
+
* @param {string | null} cssClassName optional css class to add to the highlight span element
|
|
1026
|
+
* @return {Element[]} the elements that were created to highlight the text
|
|
1027
|
+
*/
|
|
1028
|
+
export function renderHighlight(textLayer, textFragment, cssClassName = null) {
|
|
1029
|
+
// Create a range that encompasses the entire text content
|
|
1030
|
+
const firstPageNode = getFirstMostNode(textLayer);
|
|
1031
|
+
const lastPageNode = getLastMostNode(textLayer);
|
|
1032
|
+
const wholePageRange = new Range();
|
|
1033
|
+
wholePageRange.setStart(firstPageNode, 0);
|
|
1034
|
+
wholePageRange.setEnd(lastPageNode, lastPageNode.textContent.length);
|
|
1035
|
+
|
|
1036
|
+
const pageWordNodes = Array.from(genFilter(treeWalkRange(wholePageRange, 'text'), isBRVisibleTextNode));
|
|
1037
|
+
|
|
1038
|
+
const broadRanges = findRangeForRegExp(
|
|
1039
|
+
textFragment.toRegExp({ context: true }),
|
|
1040
|
+
wholePageRange,
|
|
1041
|
+
pageWordNodes,
|
|
1042
|
+
{ normalize: replaceTextFragmentDelimiters },
|
|
1043
|
+
);
|
|
1044
|
+
if (!broadRanges) {
|
|
1045
|
+
console.warn("Could not find quote with context in page");
|
|
1046
|
+
return;
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
const broadRangeWordNodes = Array.from(genFilter(treeWalkRange(broadRanges[0], 'text'), isBRVisibleTextNode));
|
|
1050
|
+
|
|
1051
|
+
// At which point the quote should now be unambiguous!
|
|
1052
|
+
// FIXME: Alas no, it is ambiguous ; need to likely extract prefix and suffix separately?
|
|
1053
|
+
const exactRanges = findRangeForRegExp(
|
|
1054
|
+
textFragment.toRegExp({ context: false }),
|
|
1055
|
+
broadRanges[0],
|
|
1056
|
+
broadRangeWordNodes,
|
|
1057
|
+
{ normalize: replaceTextFragmentDelimiters },
|
|
1058
|
+
);
|
|
1059
|
+
if (!exactRanges) {
|
|
1060
|
+
throw new Error("Could not find quote in page");
|
|
1061
|
+
}
|
|
1062
|
+
const startTextNode = getFirstMostNode(exactRanges[0].startContainer);
|
|
1063
|
+
const endTextNode = getFirstMostNode(exactRanges[0].endContainer);
|
|
1064
|
+
|
|
1065
|
+
// markRange requires the range to start and end within text nodes
|
|
1066
|
+
const exactRangeTextNodes = new Range();
|
|
1067
|
+
exactRangeTextNodes.setStart(startTextNode, 0);
|
|
1068
|
+
exactRangeTextNodes.setEnd(endTextNode, endTextNode.textContent.length);
|
|
1069
|
+
|
|
1070
|
+
// Don't mark if already marked
|
|
1071
|
+
if (startTextNode.parentElement.classList.contains("BRhighlight") ||
|
|
1072
|
+
endTextNode.parentElement.classList.contains("BRhighlight")) {
|
|
1073
|
+
return;
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
return markRange(exactRangeTextNodes, () => {
|
|
1077
|
+
const mark = document.createElement("mark");
|
|
1078
|
+
mark.classList.add("BRhighlight");
|
|
1079
|
+
if (cssClassName) mark.classList.add(cssClassName);
|
|
1080
|
+
if (textFragment.uuid) mark.classList.add(textFragment.uuid);
|
|
1081
|
+
return mark;
|
|
1082
|
+
});
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
/**
|
|
1086
|
+
* @param {Text} node
|
|
1087
|
+
*/
|
|
1088
|
+
export function isBRVisibleTextNode(node) {
|
|
1089
|
+
// Exclude the duplicated spaces between words for MS Edge
|
|
1090
|
+
return !node.previousElementSibling?.classList.contains("BRspace");
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
/**
|
|
1094
|
+
* Given a Range, wraps its text contents in one or more <mark> elements.
|
|
1095
|
+
* <mark> elements can't cross block boundaries, so this function walks the
|
|
1096
|
+
* tree to find all the relevant text nodes and wraps them.
|
|
1097
|
+
* @param {Range} range - the range to mark. Must start and end inside of
|
|
1098
|
+
* text nodes.
|
|
1099
|
+
* @param {function(): Element} createWrappingElement - a function that creates
|
|
1100
|
+
* the element to wrap text nodes in. This is called once per text node.
|
|
1101
|
+
* @return {Element[]} The <mark> nodes that were created.
|
|
1102
|
+
*
|
|
1103
|
+
* Lightly adapted from https://github.com/GoogleChromeLabs/text-fragments-polyfill/blob/abc6ed408b3f20e91d9cbda9977748459f5e3877/src/text-fragment-utils.js#L456
|
|
1104
|
+
*/
|
|
1105
|
+
function markRange(
|
|
1106
|
+
range,
|
|
1107
|
+
createWrappingElement = () => document.createElement("mark"),
|
|
1108
|
+
) {
|
|
1109
|
+
if (range.startContainer.nodeType != Node.TEXT_NODE ||
|
|
1110
|
+
range.endContainer.nodeType != Node.TEXT_NODE)
|
|
1111
|
+
return [];
|
|
1112
|
+
|
|
1113
|
+
// If the range is entirely within a single node, just surround it.
|
|
1114
|
+
if (range.startContainer === range.endContainer) {
|
|
1115
|
+
const trivialMark = createWrappingElement();
|
|
1116
|
+
range.surroundContents(trivialMark);
|
|
1117
|
+
return [trivialMark];
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
// Start node -- special case
|
|
1121
|
+
const startNode = range.startContainer;
|
|
1122
|
+
const startNodeSubrange = range.cloneRange();
|
|
1123
|
+
startNodeSubrange.setEndAfter(startNode);
|
|
1124
|
+
|
|
1125
|
+
// End node -- special case
|
|
1126
|
+
const endNode = range.endContainer;
|
|
1127
|
+
const endNodeSubrange = range.cloneRange();
|
|
1128
|
+
endNodeSubrange.setStartBefore(endNode);
|
|
1129
|
+
|
|
1130
|
+
// In between nodes
|
|
1131
|
+
const marks = [];
|
|
1132
|
+
range.setStartAfter(startNode);
|
|
1133
|
+
range.setEndBefore(endNode);
|
|
1134
|
+
const walker = document.createTreeWalker(
|
|
1135
|
+
range.commonAncestorContainer,
|
|
1136
|
+
NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT,
|
|
1137
|
+
{
|
|
1138
|
+
acceptNode: function(node) {
|
|
1139
|
+
if (!range.intersectsNode(node)) return NodeFilter.FILTER_REJECT;
|
|
1140
|
+
|
|
1141
|
+
if (node.nodeType === Node.TEXT_NODE)
|
|
1142
|
+
return NodeFilter.FILTER_ACCEPT;
|
|
1143
|
+
return NodeFilter.FILTER_SKIP;
|
|
1144
|
+
},
|
|
1145
|
+
},
|
|
1146
|
+
);
|
|
1147
|
+
let node = walker.nextNode();
|
|
1148
|
+
while (node) {
|
|
1149
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
1150
|
+
const mark = createWrappingElement();
|
|
1151
|
+
node.parentNode.insertBefore(mark, node);
|
|
1152
|
+
mark.appendChild(node);
|
|
1153
|
+
marks.push(mark);
|
|
1154
|
+
}
|
|
1155
|
+
node = walker.nextNode();
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
const startMark = createWrappingElement();
|
|
1159
|
+
startNodeSubrange.surroundContents(startMark);
|
|
1160
|
+
const endMark = createWrappingElement();
|
|
1161
|
+
endNodeSubrange.surroundContents(endMark);
|
|
1162
|
+
|
|
1163
|
+
return [startMark, ...marks, endMark];
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
/**
|
|
1167
|
+
* Get UUID assigned to the highlight element from class list
|
|
1168
|
+
* @param {HTMLElement} ele
|
|
1169
|
+
* @returns
|
|
1170
|
+
*/
|
|
1171
|
+
function retrieveUUID(ele) {
|
|
1172
|
+
if (!ele) return null;
|
|
1173
|
+
const findUUID = Array.from(ele?.classList).filter((name) => {
|
|
1174
|
+
if (name.slice(0, 2).includes('id')) {
|
|
1175
|
+
return name;
|
|
1176
|
+
}
|
|
1177
|
+
});
|
|
1178
|
+
if (findUUID.length) {
|
|
1179
|
+
return findUUID[0];
|
|
1180
|
+
}
|
|
1181
|
+
return null;
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
/**
|
|
1185
|
+
* An extension of the fields defined by the browser-native TextFragment;
|
|
1186
|
+
* See https://developer.mozilla.org/en-US/docs/Web/URI/Reference/Fragment/Text_fragments
|
|
1187
|
+
*
|
|
1188
|
+
* Text fragment string format: `pageNumber:prefix-,quote,-suffix`
|
|
1189
|
+
* Or for long quotes: `pageNumber:prefix-,quoteStart,quoteEnd,-suffix`
|
|
1190
|
+
* Note the ':' and ',' separators must not be encoded, but
|
|
1191
|
+
* the pageNumber, prefix, quote/quoteStart/quoteEnd, and suffix text can be encoded.
|
|
1192
|
+
*/
|
|
1193
|
+
export class BookReaderTextFragment {
|
|
1194
|
+
/**
|
|
1195
|
+
* @param {object} params
|
|
1196
|
+
* @param {string | null}params.prefix
|
|
1197
|
+
* @param {string | null} [params.quote]
|
|
1198
|
+
* @param {string | null} [params.quoteStart]
|
|
1199
|
+
* @param {string | null} [params.quoteEnd]
|
|
1200
|
+
* @param {string | null} params.suffix
|
|
1201
|
+
* @param {string | null} params.pageNumber Page number; e.g. asserted page number or the n-prefixed page index
|
|
1202
|
+
* @param {number} params.pageIndex Page index; e.g. zero-based index of the page
|
|
1203
|
+
* @param {string | null} [params.uuid] UUID for the text fragment if it has one
|
|
1204
|
+
*/
|
|
1205
|
+
constructor({ prefix, quote, quoteStart, quoteEnd, suffix, pageNumber, pageIndex, uuid }) {
|
|
1206
|
+
/** @type {string|null} */
|
|
1207
|
+
this.prefix = prefix;
|
|
1208
|
+
/** @type {string | null} */
|
|
1209
|
+
this.quote = quote ?? null;
|
|
1210
|
+
/** @type {string | null} */
|
|
1211
|
+
this.quoteStart = quoteStart ?? null;
|
|
1212
|
+
/** @type {string | null} */
|
|
1213
|
+
this.quoteEnd = quoteEnd ?? null;
|
|
1214
|
+
/** @type {string|null} */
|
|
1215
|
+
this.suffix = suffix;
|
|
1216
|
+
/** @type {string|null} Page number; e.g. asserted page number or the n-prefixed page index */
|
|
1217
|
+
this.pageNumber = pageNumber;
|
|
1218
|
+
/** @type {number} Page index; e.g. zero-based index of the page */
|
|
1219
|
+
this.pageIndex = pageIndex;
|
|
1220
|
+
/** @type {string | null} UUID for the text fragment if it has one */
|
|
1221
|
+
this.uuid = uuid ?? null;
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
/**
|
|
1225
|
+
* Parse a text fragment from its string value (the part after `text=`), already
|
|
1226
|
+
* URI-decoded (e.g. as returned by `URLSearchParams.get`).
|
|
1227
|
+
* Expected format: `pageNumber:prefix-,quote,-suffix`
|
|
1228
|
+
* @param {string} str
|
|
1229
|
+
* @param {import('@/src/BookReader/BookModel.js').BookModel} book
|
|
1230
|
+
* @param {number} fallbackPageIndex A fallback page index to use if the text
|
|
1231
|
+
* fragment does not specify a page number or page index.
|
|
1232
|
+
* @returns {BookReaderTextFragment}
|
|
1233
|
+
*/
|
|
1234
|
+
static fromString(str, book, fallbackPageIndex) {
|
|
1235
|
+
// Basically matches:
|
|
1236
|
+
// (stuff):(stuff)-,(stuff),-(stuff)
|
|
1237
|
+
const match = str.match(/^(?:(.*?):)?(?:(.*?)-,)?(.*?)(?:,-(.*))?$/);
|
|
1238
|
+
if (!match) {
|
|
1239
|
+
throw new Error(`Invalid text fragment format: ${str}`);
|
|
1240
|
+
}
|
|
1241
|
+
const pageNumber = match[1] || null;
|
|
1242
|
+
const prefix = match[2] || null;
|
|
1243
|
+
const quoteRegion = match[3] || '';
|
|
1244
|
+
const quoteParts = quoteRegion.split(',');
|
|
1245
|
+
let quote = null;
|
|
1246
|
+
let quoteStart = null;
|
|
1247
|
+
let quoteEnd = null;
|
|
1248
|
+
|
|
1249
|
+
if (quoteParts.length === 1) {
|
|
1250
|
+
quote = quoteParts[0];
|
|
1251
|
+
} else if (quoteParts.length === 2) {
|
|
1252
|
+
quoteStart = quoteParts[0];
|
|
1253
|
+
quoteEnd = quoteParts[1];
|
|
1254
|
+
} else {
|
|
1255
|
+
throw new Error(`Invalid text fragment quote format: ${str}`);
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
const suffix = match[4] || null;
|
|
1259
|
+
const pageIndex = pageNumber ? book.getPageIndex(pageNumber) : fallbackPageIndex;
|
|
1260
|
+
if (typeof pageIndex !== 'number') {
|
|
1261
|
+
throw new Error(`Could not determine page index for text fragment with page number ${pageNumber}`);
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
if (!quote && (!quoteStart || !quoteEnd)) {
|
|
1265
|
+
throw new Error(`Invalid text fragment quote format: ${str}`);
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
return new BookReaderTextFragment({
|
|
1269
|
+
prefix,
|
|
1270
|
+
quote,
|
|
1271
|
+
quoteStart,
|
|
1272
|
+
quoteEnd,
|
|
1273
|
+
suffix,
|
|
1274
|
+
pageNumber,
|
|
1275
|
+
pageIndex,
|
|
1276
|
+
});
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
/**
|
|
1280
|
+
* Generates a URL pointing to the page with the text fragment highlighted. Maintains all existing
|
|
1281
|
+
* parameters in the url.
|
|
1282
|
+
*/
|
|
1283
|
+
toUrl() {
|
|
1284
|
+
const params = new URLSearchParams(window.location.search);
|
|
1285
|
+
params.set('text', this.toNormalizedString());
|
|
1286
|
+
|
|
1287
|
+
const currentUrl = window.location;
|
|
1288
|
+
const pageNum = this.pageNumber || `n${this.pageIndex}`;
|
|
1289
|
+
const adjustedUrlPageNumPath = currentUrl.pathname.replace(/((?:^|[#/])page)\/[^/]+/, `$1/${pageNum}`);
|
|
1290
|
+
const hash = currentUrl.hash ? currentUrl.hash.replace(/((?:^|[#/])page)\/[^/]+/, `$1/${pageNum}`) : '';
|
|
1291
|
+
return `${currentUrl.origin}${adjustedUrlPageNumPath}?${params}${hash}`;
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
/**
|
|
1295
|
+
* Outputs a normalized, auto-truncated string representation of the text fragment,
|
|
1296
|
+
* suitable for use in a URL. This is the inverse of `fromString`. Not URI-encoded; that
|
|
1297
|
+
* is left to the caller (e.g. URLSearchParams).
|
|
1298
|
+
*
|
|
1299
|
+
* Format: `pageNumber:prefix-,quote,-suffix`
|
|
1300
|
+
* Or for long quotes: `pageNumber:prefix-,quoteStart,quoteEnd,-suffix`
|
|
1301
|
+
* The prefix and suffix are optional. Whitespace is collapsed and any text fragment
|
|
1302
|
+
* delimiters (`:`, `,`, `-,`, `,-`) in the text are replaced with spaces.
|
|
1303
|
+
*
|
|
1304
|
+
* @returns {string}
|
|
1305
|
+
*/
|
|
1306
|
+
toNormalizedString() {
|
|
1307
|
+
// When constructing to url, we can collapse the whitespace since the corresponding
|
|
1308
|
+
// toRegExp method is resilient to multiple whitespace characters.
|
|
1309
|
+
/** @param {string} s */
|
|
1310
|
+
const normalize = s => collapseWhitespace(replaceTextFragmentDelimiters(s).trim());
|
|
1311
|
+
|
|
1312
|
+
// First the page number or index
|
|
1313
|
+
let str = this.pageNumber ? this.pageNumber : `n${this.pageIndex}`;
|
|
1314
|
+
str += ':';
|
|
1315
|
+
|
|
1316
|
+
if (this.prefix) {
|
|
1317
|
+
str += normalize(this.prefix);
|
|
1318
|
+
str += '-,';
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
const quote = this.quote ? normalize(this.quote) : null;
|
|
1322
|
+
let shortenedQuoteParts = null;
|
|
1323
|
+
if (quote && quote.length > MAX_FULL_QUOTE_URL_CHARS) {
|
|
1324
|
+
const words = quote.match(/\S+/g) || [];
|
|
1325
|
+
if (words.length >= TRUNCATED_QUOTE_WORD_COUNT * 2) {
|
|
1326
|
+
const quoteStart = getFirstWords(TRUNCATED_QUOTE_WORD_COUNT, quote);
|
|
1327
|
+
const quoteEnd = getLastWords(TRUNCATED_QUOTE_WORD_COUNT, quote);
|
|
1328
|
+
if (quoteStart && quoteEnd) {
|
|
1329
|
+
shortenedQuoteParts = { quoteStart, quoteEnd };
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
if (quote && !shortenedQuoteParts) {
|
|
1335
|
+
str += quote;
|
|
1336
|
+
} else if (shortenedQuoteParts) {
|
|
1337
|
+
str += `${shortenedQuoteParts.quoteStart},${shortenedQuoteParts.quoteEnd}`;
|
|
1338
|
+
} else if (this.quoteStart && this.quoteEnd) {
|
|
1339
|
+
str += `${this.quoteStart},${this.quoteEnd}`;
|
|
1340
|
+
} else {
|
|
1341
|
+
throw new Error('Text fragment requires either a quote or quoteStart/quoteEnd');
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
if (this.suffix) {
|
|
1345
|
+
str += ',-';
|
|
1346
|
+
str += normalize(this.suffix);
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
return str;
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
/**
|
|
1353
|
+
* Build a regex that matches this quote payload.
|
|
1354
|
+
* @param {{ context?: boolean }} [options]
|
|
1355
|
+
* @returns {RegExp}
|
|
1356
|
+
*/
|
|
1357
|
+
toRegExp({ context = false } = {}) {
|
|
1358
|
+
/** @type {[String] | [String, String]} */
|
|
1359
|
+
const quotes = this.quote ? [this.quote] : [this.quoteStart, this.quoteEnd];
|
|
1360
|
+
|
|
1361
|
+
if (context) {
|
|
1362
|
+
if (this.prefix) quotes[0] = this.prefix + ' ' + quotes[0];
|
|
1363
|
+
if (this.suffix) quotes[quotes.length - 1] = quotes[quotes.length - 1] + ' ' + this.suffix;
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
// Make it resilient to extra whitespace
|
|
1367
|
+
/** @param {String} quote */
|
|
1368
|
+
const quoteToRegExpString = (quote) => RegExp.escape(replaceTextFragmentDelimiters(quote)).replace(/(\\x20)+/g, '\\s+');
|
|
1369
|
+
|
|
1370
|
+
if (quotes.length === 1) {
|
|
1371
|
+
return new RegExp(quoteToRegExpString(quotes[0]), 'gi');
|
|
1372
|
+
} else {
|
|
1373
|
+
return new RegExp(
|
|
1374
|
+
quoteToRegExpString(quotes[0]) + '[\\s\\S]*?' + quoteToRegExpString(quotes[1]),
|
|
1375
|
+
'gi',
|
|
1376
|
+
);
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
toJSON() {
|
|
1381
|
+
return {
|
|
1382
|
+
prefix: this.prefix,
|
|
1383
|
+
quote: this.quote,
|
|
1384
|
+
quoteStart: this.quoteStart,
|
|
1385
|
+
quoteEnd: this.quoteEnd,
|
|
1386
|
+
suffix: this.suffix,
|
|
1387
|
+
pageNumber: this.pageNumber,
|
|
1388
|
+
pageIndex: this.pageIndex,
|
|
1389
|
+
uuid: this.uuid,
|
|
1390
|
+
};
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
/**
|
|
1394
|
+
* Create a BookReaderTextFragment instance from a JSON object.
|
|
1395
|
+
* @param {object} json
|
|
1396
|
+
* @returns {BookReaderTextFragment}
|
|
1397
|
+
*/
|
|
1398
|
+
static fromJSON(json) {
|
|
1399
|
+
return new BookReaderTextFragment(json);
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
/**
|
|
1403
|
+
* Builds a TextFragment string from a given text selection.
|
|
1404
|
+
* @param {Selection} selection currently selected text, eg `document.getSelection()`
|
|
1405
|
+
* @param {Element[]} contextElements elements providing context for the selection
|
|
1406
|
+
* @returns {BookReaderTextFragment}
|
|
1407
|
+
*/
|
|
1408
|
+
static fromSelection(selection, contextElements) {
|
|
1409
|
+
const range = selection.getRangeAt(0);
|
|
1410
|
+
|
|
1411
|
+
// Partially selected words need to be captured completely
|
|
1412
|
+
const fullQuoteRange = new Range();
|
|
1413
|
+
const startTextNode = getLastMostNode(range.startContainer);
|
|
1414
|
+
const endTextNode = getFirstMostNode(range.endContainer);
|
|
1415
|
+
fullQuoteRange.setStart(startTextNode, 0);
|
|
1416
|
+
fullQuoteRange.setEnd(endTextNode, range.endOffset == 0 ? 0 : (endTextNode.textContent?.length ?? 0));
|
|
1417
|
+
|
|
1418
|
+
const preStartRange = document.createRange();
|
|
1419
|
+
preStartRange.setStart(getFirstMostNode(contextElements[0]), 0);
|
|
1420
|
+
preStartRange.setEnd(startTextNode, 0);
|
|
1421
|
+
|
|
1422
|
+
const postEndRange = document.createRange();
|
|
1423
|
+
postEndRange.setStart(endTextNode, fullQuoteRange.endOffset);
|
|
1424
|
+
const lastWordOfPageEl = getLastMostNode(contextElements[contextElements.length - 1]);
|
|
1425
|
+
postEndRange.setEnd(lastWordOfPageEl, Math.max(0, lastWordOfPageEl.textContent.length - 1));
|
|
1426
|
+
|
|
1427
|
+
const CONTEXT_WORD_COUNT = 3;
|
|
1428
|
+
|
|
1429
|
+
const preStartText = textLayerRangeToString(preStartRange);
|
|
1430
|
+
let prefix = getLastWords(CONTEXT_WORD_COUNT, preStartText);
|
|
1431
|
+
let prefixWords = countWords(prefix);
|
|
1432
|
+
|
|
1433
|
+
const postEndText = textLayerRangeToString(postEndRange);
|
|
1434
|
+
let suffix = getFirstWords(CONTEXT_WORD_COUNT, postEndText);
|
|
1435
|
+
let suffixWords = countWords(suffix);
|
|
1436
|
+
|
|
1437
|
+
if (prefixWords < CONTEXT_WORD_COUNT) {
|
|
1438
|
+
// Ran out of words! To reduce the risk of ambiguity, try extending suffix
|
|
1439
|
+
suffix = getFirstWords(2 * CONTEXT_WORD_COUNT - prefixWords, postEndText);
|
|
1440
|
+
suffixWords = countWords(suffix);
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
if (suffixWords < CONTEXT_WORD_COUNT) {
|
|
1444
|
+
// Ran out of words on the suffix side as well, try extending prefix
|
|
1445
|
+
prefix = getLastWords(2 * CONTEXT_WORD_COUNT - suffixWords, preStartText);
|
|
1446
|
+
prefixWords = countWords(prefix);
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
const quote = textLayerRangeToString(fullQuoteRange);
|
|
1450
|
+
const pageContainerEl = startTextNode.parentElement.closest(".BRpagecontainer");
|
|
1451
|
+
|
|
1452
|
+
return new BookReaderTextFragment({
|
|
1453
|
+
quote,
|
|
1454
|
+
prefix,
|
|
1455
|
+
suffix,
|
|
1456
|
+
pageNumber: pageContainerEl.getAttribute("data-page-num"),
|
|
1457
|
+
pageIndex: parseFloat(pageContainerEl.getAttribute("data-index")),
|
|
1458
|
+
});
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
/**
|
|
1463
|
+
* @typedef {BookReaderTextFragment & { uuid: string }} BookReaderSavedHighlight
|
|
1464
|
+
*/
|