@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.
Files changed (420) hide show
  1. package/BookReader/474.js +2 -0
  2. package/BookReader/474.js.map +1 -0
  3. package/BookReader/BookReader.css +670 -1190
  4. package/BookReader/bergamot-translator-worker.js +2966 -0
  5. package/BookReader/bergamot-translator-worker.wasm +0 -0
  6. package/BookReader/hypothesis/LICENSE +50 -0
  7. package/BookReader/hypothesis/README.md +55 -0
  8. package/BookReader/hypothesis/build/boot-template.js +1 -0
  9. package/BookReader/hypothesis/build/boot.js +1 -0
  10. package/BookReader/hypothesis/build/manifest.json +20 -0
  11. package/BookReader/hypothesis/build/scripts/annotator.bundle.js +199 -0
  12. package/BookReader/hypothesis/build/scripts/annotator.bundle.js.map +1 -0
  13. package/BookReader/hypothesis/build/scripts/sidebar.bundle.js +811 -0
  14. package/BookReader/hypothesis/build/scripts/sidebar.bundle.js.map +1 -0
  15. package/BookReader/hypothesis/build/scripts/ui-playground.bundle.js +437 -0
  16. package/BookReader/hypothesis/build/scripts/ui-playground.bundle.js.map +1 -0
  17. package/BookReader/hypothesis/build/styles/annotator.css +4 -0
  18. package/BookReader/hypothesis/build/styles/annotator.css.map +1 -0
  19. package/BookReader/hypothesis/build/styles/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  20. package/BookReader/hypothesis/build/styles/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  21. package/BookReader/hypothesis/build/styles/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  22. package/BookReader/hypothesis/build/styles/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  23. package/BookReader/hypothesis/build/styles/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  24. package/BookReader/hypothesis/build/styles/fonts/KaTeX_Main-Bold.woff2 +0 -0
  25. package/BookReader/hypothesis/build/styles/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  26. package/BookReader/hypothesis/build/styles/fonts/KaTeX_Main-Italic.woff2 +0 -0
  27. package/BookReader/hypothesis/build/styles/fonts/KaTeX_Main-Regular.woff2 +0 -0
  28. package/BookReader/hypothesis/build/styles/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  29. package/BookReader/hypothesis/build/styles/fonts/KaTeX_Math-Italic.woff2 +0 -0
  30. package/BookReader/hypothesis/build/styles/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  31. package/BookReader/hypothesis/build/styles/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  32. package/BookReader/hypothesis/build/styles/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  33. package/BookReader/hypothesis/build/styles/fonts/KaTeX_Script-Regular.woff2 +0 -0
  34. package/BookReader/hypothesis/build/styles/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  35. package/BookReader/hypothesis/build/styles/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  36. package/BookReader/hypothesis/build/styles/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  37. package/BookReader/hypothesis/build/styles/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  38. package/BookReader/hypothesis/build/styles/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  39. package/BookReader/hypothesis/build/styles/highlights.css +2 -0
  40. package/BookReader/hypothesis/build/styles/highlights.css.map +1 -0
  41. package/BookReader/hypothesis/build/styles/katex.min.css +3 -0
  42. package/BookReader/hypothesis/build/styles/katex.min.css.map +1 -0
  43. package/BookReader/hypothesis/build/styles/pdfjs-overrides.css +14 -0
  44. package/BookReader/hypothesis/build/styles/pdfjs-overrides.css.map +1 -0
  45. package/BookReader/hypothesis/build/styles/sidebar.css +4 -0
  46. package/BookReader/hypothesis/build/styles/sidebar.css.map +1 -0
  47. package/BookReader/hypothesis/build/styles/ui-playground.css +4 -0
  48. package/BookReader/hypothesis/build/styles/ui-playground.css.map +1 -0
  49. package/BookReader/hypothesis/package.json +126 -0
  50. package/BookReader/ia-bookreader.js +1886 -0
  51. package/BookReader/{BookReader.js.LICENSE.txt → ia-bookreader.js.LICENSE.txt} +26 -20
  52. package/BookReader/ia-bookreader.js.map +1 -0
  53. package/BookReader/icons/1up.svg +1 -12
  54. package/BookReader/icons/2up.svg +1 -15
  55. package/BookReader/icons/advance.svg +3 -26
  56. package/BookReader/icons/chevron-right.svg +1 -1
  57. package/BookReader/icons/close-circle-dark.svg +1 -1
  58. package/BookReader/icons/close-circle.svg +1 -1
  59. package/BookReader/icons/fullscreen.svg +1 -17
  60. package/BookReader/icons/fullscreen_exit.svg +1 -17
  61. package/BookReader/icons/hamburger.svg +1 -15
  62. package/BookReader/icons/left-arrow.svg +1 -12
  63. package/BookReader/icons/magnify-minus.svg +1 -16
  64. package/BookReader/icons/magnify-plus.svg +1 -17
  65. package/BookReader/icons/magnify.svg +1 -15
  66. package/BookReader/icons/pause.svg +1 -23
  67. package/BookReader/icons/play.svg +1 -22
  68. package/BookReader/icons/playback-speed.svg +1 -34
  69. package/BookReader/icons/read-aloud.svg +1 -22
  70. package/BookReader/icons/review.svg +3 -22
  71. package/BookReader/icons/slider-toggle.svg +1 -0
  72. package/BookReader/icons/thumbnails.svg +1 -17
  73. package/BookReader/icons/voice.svg +1 -0
  74. package/BookReader/icons/volume-full.svg +1 -22
  75. package/BookReader/images/BRicons.svg +5 -94
  76. package/BookReader/images/books_graphic.svg +1 -177
  77. package/BookReader/images/hypothesis.ico +0 -0
  78. package/BookReader/images/icon_book.svg +1 -12
  79. package/BookReader/images/icon_bookmark.svg +1 -12
  80. package/BookReader/images/icon_experiment.svg +1 -0
  81. package/BookReader/images/icon_gear.svg +1 -14
  82. package/BookReader/images/icon_hamburger.svg +1 -20
  83. package/BookReader/images/icon_home.svg +1 -21
  84. package/BookReader/images/icon_info.svg +1 -11
  85. package/BookReader/images/icon_one_page.svg +1 -8
  86. package/BookReader/images/icon_pause.svg +1 -1
  87. package/BookReader/images/icon_play.svg +1 -1
  88. package/BookReader/images/icon_playback-rate.svg +1 -15
  89. package/BookReader/images/icon_search_button.svg +1 -8
  90. package/BookReader/images/icon_share.svg +1 -9
  91. package/BookReader/images/icon_skip-ahead.svg +1 -6
  92. package/BookReader/images/icon_skip-back.svg +2 -13
  93. package/BookReader/images/icon_speaker.svg +1 -18
  94. package/BookReader/images/icon_speaker_open.svg +1 -10
  95. package/BookReader/images/icon_thumbnails.svg +1 -12
  96. package/BookReader/images/icon_toc.svg +1 -5
  97. package/BookReader/images/icon_two_pages.svg +1 -9
  98. package/BookReader/images/marker_chap-off.svg +1 -11
  99. package/BookReader/images/marker_chap-on.svg +1 -11
  100. package/BookReader/images/marker_srch-on.svg +1 -11
  101. package/BookReader/images/translate.svg +1 -0
  102. package/BookReader/images/unviewable_page.png +0 -0
  103. package/BookReader/jquery-3.js +2 -0
  104. package/BookReader/jquery-3.js.LICENSE.txt +24 -0
  105. package/BookReader/plugins/plugin.archive_analytics.js +1 -172
  106. package/BookReader/plugins/plugin.archive_analytics.js.map +1 -1
  107. package/BookReader/plugins/plugin.autoplay.js +1 -165
  108. package/BookReader/plugins/plugin.autoplay.js.map +1 -1
  109. package/BookReader/plugins/plugin.chapters.js +19 -301
  110. package/BookReader/plugins/plugin.chapters.js.LICENSE.txt +1 -0
  111. package/BookReader/plugins/plugin.chapters.js.map +1 -1
  112. package/BookReader/plugins/plugin.experiments.js +3 -0
  113. package/BookReader/plugins/plugin.experiments.js.LICENSE.txt +1 -0
  114. package/BookReader/plugins/plugin.experiments.js.map +1 -0
  115. package/BookReader/plugins/plugin.iframe.js +1 -74
  116. package/BookReader/plugins/plugin.iframe.js.map +1 -1
  117. package/BookReader/plugins/plugin.iiif.js +2 -0
  118. package/BookReader/plugins/plugin.iiif.js.map +1 -0
  119. package/BookReader/plugins/plugin.resume.js +1 -368
  120. package/BookReader/plugins/plugin.resume.js.map +1 -1
  121. package/BookReader/plugins/plugin.search.js +2 -1385
  122. package/BookReader/plugins/plugin.search.js.LICENSE.txt +1 -0
  123. package/BookReader/plugins/plugin.search.js.map +1 -1
  124. package/BookReader/plugins/plugin.text_selection.js +2 -1080
  125. package/BookReader/plugins/plugin.text_selection.js.LICENSE.txt +1 -0
  126. package/BookReader/plugins/plugin.text_selection.js.map +1 -1
  127. package/BookReader/plugins/plugin.translate.js +3 -0
  128. package/BookReader/plugins/plugin.translate.js.LICENSE.txt +1 -0
  129. package/BookReader/plugins/plugin.translate.js.map +1 -0
  130. package/BookReader/plugins/plugin.tts.js +2 -9193
  131. package/BookReader/plugins/plugin.tts.js.LICENSE.txt +2 -0
  132. package/BookReader/plugins/plugin.tts.js.map +1 -1
  133. package/BookReader/plugins/plugin.url.js +1 -269
  134. package/BookReader/plugins/plugin.url.js.map +1 -1
  135. package/BookReader/plugins/plugin.vendor-fullscreen.js +1 -379
  136. package/BookReader/plugins/plugin.vendor-fullscreen.js.map +1 -1
  137. package/BookReader/plugins/translator-worker.js +2 -0
  138. package/BookReader/plugins/translator-worker.js.map +1 -0
  139. package/BookReader/silence.mp3 +0 -0
  140. package/BookReader/translator-worker.js +475 -0
  141. package/BookReader/webcomponents-bundle.js +3 -0
  142. package/BookReader/webcomponents-bundle.js.LICENSE.txt +9 -0
  143. package/BookReader/webcomponents-bundle.js.map +1 -0
  144. package/README.md +17 -4
  145. package/dist/esm/ia-bookreader.js +3 -0
  146. package/dist/esm/ia-bookreader.js.LICENSE.txt +17 -0
  147. package/dist/esm/ia-bookreader.js.map +1 -0
  148. package/dist/esm/plugins/plugin.archive_analytics.js +2 -0
  149. package/dist/esm/plugins/plugin.archive_analytics.js.map +1 -0
  150. package/dist/esm/plugins/plugin.autoplay.js +2 -0
  151. package/dist/esm/plugins/plugin.autoplay.js.map +1 -0
  152. package/dist/esm/plugins/plugin.chapters.js +2 -0
  153. package/dist/esm/plugins/plugin.chapters.js.map +1 -0
  154. package/dist/esm/plugins/plugin.experiments.js +2 -0
  155. package/dist/esm/plugins/plugin.experiments.js.map +1 -0
  156. package/dist/esm/plugins/plugin.iframe.js +2 -0
  157. package/dist/esm/plugins/plugin.iframe.js.map +1 -0
  158. package/dist/esm/plugins/plugin.iiif.js +2 -0
  159. package/dist/esm/plugins/plugin.iiif.js.map +1 -0
  160. package/dist/esm/plugins/plugin.resume.js +2 -0
  161. package/dist/esm/plugins/plugin.resume.js.map +1 -0
  162. package/dist/esm/plugins/plugin.search.js +2 -0
  163. package/dist/esm/plugins/plugin.search.js.map +1 -0
  164. package/dist/esm/plugins/plugin.text_selection.js +2 -0
  165. package/dist/esm/plugins/plugin.text_selection.js.map +1 -0
  166. package/dist/esm/plugins/plugin.translate.js +2 -0
  167. package/dist/esm/plugins/plugin.translate.js.map +1 -0
  168. package/dist/esm/plugins/plugin.tts.js +2 -0
  169. package/dist/esm/plugins/plugin.tts.js.map +1 -0
  170. package/dist/esm/plugins/plugin.url.js +2 -0
  171. package/dist/esm/plugins/plugin.url.js.map +1 -0
  172. package/dist/esm/plugins/plugin.vendor-fullscreen.js +2 -0
  173. package/dist/esm/plugins/plugin.vendor-fullscreen.js.map +1 -0
  174. package/jsconfig.json +18 -0
  175. package/package.json +113 -71
  176. package/src/BookReader/BookModel.js +92 -46
  177. package/src/BookReader/DragScrollable.js +233 -0
  178. package/src/BookReader/ImageCache.js +49 -16
  179. package/src/BookReader/Mode1Up.js +66 -365
  180. package/src/BookReader/Mode1UpLit.js +392 -0
  181. package/src/BookReader/Mode2Up.js +87 -1315
  182. package/src/BookReader/Mode2UpLit.js +786 -0
  183. package/src/BookReader/ModeAbstract.js +43 -0
  184. package/src/BookReader/ModeCoordinateSpace.js +29 -0
  185. package/src/BookReader/ModeSmoothZoom.js +312 -0
  186. package/src/BookReader/ModeThumb.js +29 -15
  187. package/src/BookReader/Navbar/Navbar.js +204 -84
  188. package/src/BookReader/PageContainer.js +120 -23
  189. package/src/BookReader/ReduceSet.js +2 -2
  190. package/src/BookReader/Toolbar/Toolbar.js +18 -40
  191. package/src/BookReader/events.js +3 -3
  192. package/src/BookReader/options.js +105 -18
  193. package/src/BookReader/utils/HTMLDimensionsCacher.js +44 -0
  194. package/src/BookReader/utils/ScrollClassAdder.js +31 -0
  195. package/src/BookReader/utils/SelectionObserver.js +72 -0
  196. package/src/BookReader/utils/classes.js +1 -1
  197. package/src/BookReader/utils.js +136 -12
  198. package/src/BookReader.js +732 -1231
  199. package/src/BookReaderPlugin.js +52 -0
  200. package/src/assets/icons/magnify-minus.svg +3 -7
  201. package/src/assets/icons/magnify-plus.svg +3 -7
  202. package/src/assets/icons/slider-toggle.svg +1 -0
  203. package/src/assets/icons/voice.svg +1 -0
  204. package/src/assets/images/hypothesis.ico +0 -0
  205. package/src/assets/images/icon_experiment.svg +1 -0
  206. package/src/assets/images/translate.svg +1 -0
  207. package/src/assets/images/unviewable_page.png +0 -0
  208. package/src/assets/silence.mp3 +0 -0
  209. package/src/css/BookReader.scss +1 -5
  210. package/src/css/_BRBookmarks.scss +1 -1
  211. package/src/css/_BRComponent.scss +1 -1
  212. package/src/css/_BRicon.scss +8 -2
  213. package/src/css/_BRmain.scss +16 -3
  214. package/src/css/_BRnav.scss +74 -70
  215. package/src/css/_BRpages.scss +174 -42
  216. package/src/css/_BRsearch.scss +69 -30
  217. package/src/css/_BRtoolbar.scss +5 -5
  218. package/src/css/_TextSelection.scss +249 -25
  219. package/src/css/_colorbox.scss +2 -2
  220. package/src/css/_controls.scss +24 -7
  221. package/src/css/_icons.scss +8 -1
  222. package/src/{BookNavigator/assets → css}/button-base.js +2 -2
  223. package/src/css/icon_checkmark.js +9 -0
  224. package/src/css/sharedStyles.js +15 -0
  225. package/src/ia-bookreader/downloads/downloads-provider.js +81 -0
  226. package/src/{BookNavigator → ia-bookreader}/downloads/downloads.js +25 -5
  227. package/src/ia-bookreader/ia-bookreader.js +686 -0
  228. package/src/ia-bookreader/sharing.js +27 -0
  229. package/src/ia-bookreader/viewable-files.js +98 -0
  230. package/src/{BookNavigator → ia-bookreader}/visual-adjustments/visual-adjustments-provider.js +17 -17
  231. package/src/{BookNavigator → ia-bookreader}/visual-adjustments/visual-adjustments.js +75 -67
  232. package/src/{BookNavigator → plugins}/bookmarks/bookmark-button.js +4 -3
  233. package/src/{BookNavigator/assets → plugins/bookmarks}/bookmark-colors.js +1 -1
  234. package/src/{BookNavigator → plugins}/bookmarks/bookmark-edit.js +43 -31
  235. package/src/{BookNavigator → plugins}/bookmarks/bookmarks-list.js +48 -49
  236. package/src/{BookNavigator → plugins}/bookmarks/bookmarks-loginCTA.js +3 -3
  237. package/src/plugins/bookmarks/bookmarks-provider.js +63 -0
  238. package/src/{BookNavigator → plugins/bookmarks}/delete-modal-actions.js +1 -1
  239. package/src/{BookNavigator → plugins}/bookmarks/ia-bookmarks.js +117 -68
  240. package/src/plugins/plugin.archive_analytics.js +84 -78
  241. package/src/plugins/plugin.autoplay.js +99 -104
  242. package/src/plugins/plugin.chapters.js +323 -205
  243. package/src/plugins/plugin.experiments.js +375 -0
  244. package/src/plugins/plugin.iframe.js +36 -37
  245. package/src/plugins/plugin.iiif.js +147 -0
  246. package/src/plugins/plugin.resume.js +54 -51
  247. package/src/plugins/plugin.text_selection.js +464 -219
  248. package/src/plugins/plugin.vendor-fullscreen.js +5 -5
  249. package/src/plugins/search/plugin.search.js +347 -397
  250. package/src/{BookNavigator → plugins}/search/search-provider.js +59 -27
  251. package/src/{BookNavigator → plugins}/search/search-results.js +105 -76
  252. package/src/plugins/search/utils.js +50 -0
  253. package/src/plugins/search/view.js +51 -69
  254. package/src/plugins/translate/TranslationManager.js +164 -0
  255. package/src/plugins/translate/plugin.translate.js +559 -0
  256. package/src/plugins/tts/AbstractTTSEngine.js +78 -49
  257. package/src/plugins/tts/FestivalTTSEngine.js +20 -30
  258. package/src/plugins/tts/PageChunk.js +117 -23
  259. package/src/plugins/tts/PageChunkIterator.js +11 -17
  260. package/src/plugins/tts/WebTTSEngine.js +132 -92
  261. package/src/plugins/tts/plugin.tts.js +341 -351
  262. package/src/plugins/tts/utils.js +76 -67
  263. package/src/plugins/url/UrlPlugin.js +213 -0
  264. package/src/plugins/{plugin.url.js → url/plugin.url.js} +54 -18
  265. package/src/util/TextSelectionManager.js +1464 -0
  266. package/src/util/browserSniffing.js +42 -1
  267. package/src/util/cache.js +20 -0
  268. package/src/util/docCookies.js +21 -2
  269. package/src/util/dom.js +88 -0
  270. package/src/util/generators.js +89 -0
  271. package/src/util/lit.js +15 -0
  272. package/src/util/strings.js +11 -0
  273. package/.babelrc +0 -12
  274. package/.dependabot/config.yml +0 -6
  275. package/.eslintrc.js +0 -50
  276. package/.gitattributes +0 -2
  277. package/.github/ISSUE_TEMPLATE/bug.md +0 -32
  278. package/.github/ISSUE_TEMPLATE/feature-request.md +0 -30
  279. package/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md +0 -15
  280. package/.github/workflows/node.js.yml +0 -37
  281. package/.github/workflows/npm-publish.yml +0 -47
  282. package/.testcaferc.json +0 -5
  283. package/BookReader/BookReader.js +0 -21613
  284. package/BookReader/BookReader.js.map +0 -1
  285. package/BookReader/bookreader-component-bundle.js +0 -14497
  286. package/BookReader/bookreader-component-bundle.js.LICENSE.txt +0 -38
  287. package/BookReader/bookreader-component-bundle.js.map +0 -1
  288. package/BookReader/jquery-1.10.1.js +0 -108
  289. package/BookReader/jquery-1.10.1.js.LICENSE.txt +0 -24
  290. package/BookReader/plugins/plugin.menu_toggle.js +0 -369
  291. package/BookReader/plugins/plugin.menu_toggle.js.map +0 -1
  292. package/BookReader/plugins/plugin.mobile_nav.js +0 -335
  293. package/BookReader/plugins/plugin.mobile_nav.js.map +0 -1
  294. package/BookReaderDemo/BookReaderDemo.css +0 -41
  295. package/BookReaderDemo/BookReaderJSAdvanced.js +0 -115
  296. package/BookReaderDemo/BookReaderJSAutoplay.js +0 -56
  297. package/BookReaderDemo/BookReaderJSSimple.js +0 -55
  298. package/BookReaderDemo/IIIFBookReader.js +0 -207
  299. package/BookReaderDemo/assets/v5/Bookreader-logo-cool-grad.svg +0 -1
  300. package/BookReaderDemo/assets/v5/Bookreader-logo-flat.svg +0 -1
  301. package/BookReaderDemo/assets/v5/Bookreader-logo-hex-cool-grad.png +0 -0
  302. package/BookReaderDemo/assets/v5/Bookreader-logo-hex-flat.png +0 -0
  303. package/BookReaderDemo/assets/v5/Bookreader-logo-lines.png +0 -0
  304. package/BookReaderDemo/assets/v5/Bookreader-logo-lines.svg +0 -1
  305. package/BookReaderDemo/assets/v5/Bookreader-logo-warm.svg +0 -1
  306. package/BookReaderDemo/assets/v5/bookreader-logo-renders@1x.png +0 -0
  307. package/BookReaderDemo/assets/v5/bookreader-logo-renders@2x.png +0 -0
  308. package/BookReaderDemo/assets/v5/bookreader-v5-screenshot.png +0 -0
  309. package/BookReaderDemo/bookreader-template-bundle.js +0 -7178
  310. package/BookReaderDemo/demo-advanced.html +0 -33
  311. package/BookReaderDemo/demo-autoplay.html +0 -38
  312. package/BookReaderDemo/demo-embed-iframe-src.html +0 -84
  313. package/BookReaderDemo/demo-embed.html +0 -26
  314. package/BookReaderDemo/demo-fullscreen-mobile.html +0 -36
  315. package/BookReaderDemo/demo-fullscreen.html +0 -33
  316. package/BookReaderDemo/demo-iiif.html +0 -34
  317. package/BookReaderDemo/demo-iiif.js +0 -26
  318. package/BookReaderDemo/demo-internetarchive.html +0 -74
  319. package/BookReaderDemo/demo-multiple.html +0 -43
  320. package/BookReaderDemo/demo-plugin-menu-toggle.html +0 -34
  321. package/BookReaderDemo/demo-preview-pages.html +0 -1092
  322. package/BookReaderDemo/demo-simple.html +0 -34
  323. package/BookReaderDemo/demo-vendor-fullscreen.html +0 -36
  324. package/BookReaderDemo/immersion-1up.html +0 -64
  325. package/BookReaderDemo/immersion-mode.html +0 -35
  326. package/BookReaderDemo/toggle_controls.html +0 -53
  327. package/BookReaderDemo/view_mode.html +0 -39
  328. package/BookReaderDemo/viewmode-cycle.html +0 -41
  329. package/CHANGELOG.md +0 -499
  330. package/CONTRIBUTING.md +0 -7
  331. package/codecov.yml +0 -17
  332. package/index.html +0 -31
  333. package/karma.conf.js +0 -23
  334. package/screenshot.png +0 -0
  335. package/scripts/postversion.js +0 -10
  336. package/scripts/preversion.js +0 -14
  337. package/scripts/version.js +0 -26
  338. package/src/BookNavigator/BookModel.js +0 -14
  339. package/src/BookNavigator/BookNavigator.js +0 -459
  340. package/src/BookNavigator/assets/book-loader.js +0 -27
  341. package/src/BookNavigator/assets/icon_checkmark.js +0 -6
  342. package/src/BookNavigator/assets/icon_close.js +0 -3
  343. package/src/BookNavigator/assets/icon_sort_ascending.js +0 -5
  344. package/src/BookNavigator/assets/icon_sort_descending.js +0 -5
  345. package/src/BookNavigator/assets/icon_sort_neutral.js +0 -5
  346. package/src/BookNavigator/assets/icon_volumes.js +0 -11
  347. package/src/BookNavigator/bookmarks/bookmarks-provider.js +0 -53
  348. package/src/BookNavigator/br-fullscreen-mgr.js +0 -83
  349. package/src/BookNavigator/downloads/downloads-provider.js +0 -66
  350. package/src/BookNavigator/search/a-search-result.js +0 -55
  351. package/src/BookNavigator/volumes/volumes-provider.js +0 -83
  352. package/src/BookNavigator/volumes/volumes.js +0 -178
  353. package/src/BookReader/DebugConsole.js +0 -54
  354. package/src/BookReaderComponent/BookReaderComponent.js +0 -112
  355. package/src/ItemNavigator/ItemNavigator.js +0 -376
  356. package/src/ItemNavigator/providers/sharing.js +0 -33
  357. package/src/css/_MobileNav.scss +0 -194
  358. package/src/dragscrollable-br.js +0 -261
  359. package/src/plugins/menu_toggle/plugin.menu_toggle.js +0 -324
  360. package/src/plugins/plugin.mobile_nav.js +0 -287
  361. package/tests/BookReader/BookModel.test.js +0 -312
  362. package/tests/BookReader/BookReaderPublicFunctions.test.js +0 -171
  363. package/tests/BookReader/DebugConsole.test.js +0 -25
  364. package/tests/BookReader/ImageCache.test.js +0 -150
  365. package/tests/BookReader/Mode1Up.test.js +0 -164
  366. package/tests/BookReader/Mode2Up.test.js +0 -247
  367. package/tests/BookReader/Navbar/Navbar.test.js +0 -169
  368. package/tests/BookReader/PageContainer.test.js +0 -115
  369. package/tests/BookReader/ReduceSet.test.js +0 -38
  370. package/tests/BookReader/Toolbar/Toolbar.test.js +0 -26
  371. package/tests/BookReader/utils/classes.test.js +0 -88
  372. package/tests/BookReader/utils.test.js +0 -109
  373. package/tests/BookReader.options.test.js +0 -39
  374. package/tests/BookReader.test.js +0 -301
  375. package/tests/e2e/README.md +0 -75
  376. package/tests/e2e/autoplay.test.js +0 -13
  377. package/tests/e2e/base.test.js +0 -35
  378. package/tests/e2e/helpers/base.js +0 -263
  379. package/tests/e2e/helpers/debug.js +0 -13
  380. package/tests/e2e/helpers/desktopSearch.js +0 -72
  381. package/tests/e2e/helpers/mobileSearch.js +0 -85
  382. package/tests/e2e/helpers/mockSearch.js +0 -93
  383. package/tests/e2e/helpers/rightToLeft.js +0 -29
  384. package/tests/e2e/ia-production/ia-prod-base.js +0 -17
  385. package/tests/e2e/models/BookReader.js +0 -11
  386. package/tests/e2e/models/Navigation.js +0 -56
  387. package/tests/e2e/rightToLeft.test.js +0 -20
  388. package/tests/e2e/viewmode.test.js +0 -37
  389. package/tests/karma/BookNavigator/book-navigator.test.js +0 -139
  390. package/tests/karma/BookNavigator/bookmarks/bookmark-edit.test.js +0 -133
  391. package/tests/karma/BookNavigator/bookmarks/bookmarks-list.test.js +0 -222
  392. package/tests/karma/BookNavigator/search/search-provider.test.js +0 -23
  393. package/tests/karma/BookNavigator/search/search-results.test.js +0 -240
  394. package/tests/karma/BookNavigator/sharing/sharing-provider.test.js +0 -40
  395. package/tests/karma/BookNavigator/visual-adjustments.test.js +0 -201
  396. package/tests/karma/BookNavigator/volumes/volumes-provider.test.js +0 -141
  397. package/tests/karma/BookNavigator/volumes/volumes.test.js +0 -98
  398. package/tests/plugins/menu_toggle/plugin.menu_toggle.test.js +0 -68
  399. package/tests/plugins/plugin.archive_analytics.test.js +0 -23
  400. package/tests/plugins/plugin.autoplay.test.js +0 -52
  401. package/tests/plugins/plugin.chapters.test.js +0 -130
  402. package/tests/plugins/plugin.iframe.test.js +0 -42
  403. package/tests/plugins/plugin.mobile_nav.test.js +0 -66
  404. package/tests/plugins/plugin.resume.test.js +0 -98
  405. package/tests/plugins/plugin.text_selection.test.js +0 -203
  406. package/tests/plugins/plugin.url.test.js +0 -129
  407. package/tests/plugins/plugin.vendor-fullscreen.test.js +0 -65
  408. package/tests/plugins/search/plugin.search.test.js +0 -173
  409. package/tests/plugins/search/plugin.search.view.test.js +0 -106
  410. package/tests/plugins/tts/AbstractTTSEngine.test.js +0 -153
  411. package/tests/plugins/tts/FestivalTTSEngine.test.js +0 -59
  412. package/tests/plugins/tts/PageChunk.test.js +0 -57
  413. package/tests/plugins/tts/PageChunkIterator.test.js +0 -179
  414. package/tests/plugins/tts/WebTTSEngine.test.js +0 -126
  415. package/tests/plugins/tts/utils.test.js +0 -133
  416. package/tests/util/browserSniffing.test.js +0 -56
  417. package/tests/util/docCookies.test.js +0 -15
  418. package/tests/util/strings.test.js +0 -63
  419. package/tests/utils.js +0 -42
  420. 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
+ */