├── .gitignore ├── .prettierrc ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── _locales ├── ca │ └── messages.json ├── de │ └── messages.json ├── en │ └── messages.json ├── es │ └── messages.json ├── fr │ └── messages.json ├── he │ └── messages.json └── ko │ └── messages.json ├── assets ├── 1024x1024.png ├── 128x128.png ├── 16x16.png ├── 180x180.png ├── 192x192.png ├── 32x32.png ├── 512x512.png └── icon.svg ├── content_script.js ├── eslint.config.js ├── fragment-generation-utils.js ├── manifest.json ├── offscreen.html ├── offscreen.js ├── options.html ├── options.js ├── package-lock.json ├── package.json ├── prepare.js ├── privacy.md ├── service_worker.js └── store-assets ├── icon-128x128.png ├── promo-tile-440x280.png └── screenshot-contextmenu-1280x800.png /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "always", 3 | "bracketSpacing": true, 4 | "htmlWhitespaceSensitivity": "css", 5 | "insertPragma": false, 6 | "jsxBracketSameLine": false, 7 | "jsxSingleQuote": false, 8 | "printWidth": 80, 9 | "proseWrap": "preserve", 10 | "quoteProps": "as-needed", 11 | "requirePragma": false, 12 | "semi": true, 13 | "singleQuote": true, 14 | "tabWidth": 2, 15 | "trailingComma": "es5", 16 | "useTabs": false, 17 | "vueIndentScriptAndStyle": false 18 | } 19 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | All Google open source projects are covered by our [community guidelines](https://opensource.google/conduct/) which define the kind of respectful behavior we expect of all participants. 4 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to Contribute 2 | 3 | We'd love to accept your patches and contributions to this project. There are 4 | just a few small guidelines you need to follow. 5 | 6 | ## Contributor License Agreement 7 | 8 | Contributions to this project must be accompanied by a Contributor License 9 | Agreement (CLA). You (or your employer) retain the copyright to your 10 | contribution; this simply gives us permission to use and redistribute your 11 | contributions as part of the project. Head over to 12 | to see your current agreements on file or 13 | to sign a new one. 14 | 15 | You generally only need to submit a CLA once, so if you've already submitted one 16 | (even if it was for a different project), you probably don't need to do it 17 | again. 18 | 19 | ## Code reviews 20 | 21 | All submissions, including submissions by project members, require review. We 22 | use GitHub pull requests for this purpose. Consult 23 | [GitHub Help](https://help.github.com/articles/about-pull-requests/) for more 24 | information on using pull requests. 25 | 26 | ## Community Guidelines 27 | 28 | This project follows 29 | [Google's Open Source Community Guidelines](https://opensource.google/conduct/). 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Link to Text Fragment 2 | 3 | - 📖 Article: https://web.dev/text-fragments/ 4 | - 🧩 Extension: https://chrome.google.com/webstore/detail/link-to-text-fragment/pbcodcjpfjdpcineamnnmbkkmkdpajjg 5 | - 🎬 Demo video: https://www.youtube.com/watch?v=Y5DmGqnzvBI 6 | 7 | ## Installation 8 | 9 | You can install the extension in your browser of choice: 10 | 11 | - Google Chrome: [Link to Text Fragment extension](https://chrome.google.com/webstore/detail/link-to-text-fragment/pbcodcjpfjdpcineamnnmbkkmkdpajjg) 12 | - ~Microsoft Edge: Link to Text Fragment extension~ (No longer supported.) 13 | - ~Mozilla Firefox: Link to Text Fragment extension~ (No longer supported.) 14 | - ~Apple Safari: Link to Text Fragment extension~ (No longer supported.) 15 | 16 | ## Usage 17 | 18 | The Link to Text Fragment extension allows for the easy creation 19 | of text fragment URLs via the context menu: 20 | 21 | 1. Select the text that you want to link to. 22 | 1. Right-click and choose "Copy Link to Selected Text" from the context menu. 23 | 1. If the link creation succeeded, the selected text will be briefly highlighted in yellow. 24 | 1. Paste your link wherever you want to share it. 25 | 26 | ![Text fragment selected on a webpage and contextmenu showing "Copy Link to Selected Text"](https://github.com/GoogleChromeLabs/link-to-text-fragment/blob/main/store-assets/screenshot-contextmenu-1280x800.png?raw=true) 27 | 28 | ## Background 29 | 30 | The [Text Fragments specification](https://wicg.github.io/ScrollToTextFragment/) 31 | adds support for specifying a text snippet in the URL fragment. 32 | 33 | ``` 34 | #:~:text=[prefix-,]textStart[,textEnd][,-suffix] 35 | ``` 36 | 37 | When navigating to a URL with such a fragment, the user agent can quickly 38 | emphasize and/or bring it to the user's attention. 39 | 40 | Try it out by clicking on this link: 41 | https://wicg.github.io/scroll-to-text-fragment/#ref-for-fragment-directive:~:text=%23%3A~%3Atext%3D%5Bprefix%2D%2C%5DtextStart%5B%2CtextEnd%5D%5B%2C%2Dsuffix%5D. 42 | 43 | ## Acknowledgements 44 | 45 | Text Fragments was implemented and specified by 46 | [Nick Burris](https://github.com/nickburris) 47 | and [David Bokan](https://github.com/bokand), 48 | with contributions from [Grant Wang](https://github.com/grantjwang). 49 | The extension icon is courtesy of [Rombout Versluijs](https://twitter.com/romboutv). 50 | 51 | ## License 52 | 53 | The extension's source code is licensed under the terms of the Apache 2.0 license. 54 | 55 | This is not an official Google product. 56 | By installing this item, you agree to the Google Terms of Service and Privacy Policy at 57 | https://www.google.com/intl/en/policies/. 58 | -------------------------------------------------------------------------------- /_locales/ca/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "extension_name": { 3 | "message": "Enllaç al fragment de text" 4 | }, 5 | "extension_short_name": { 6 | "message": "Fragment" 7 | }, 8 | "extension_description": { 9 | "message": "Extensió del navegador que permet enllaçar a qualsevol text en una pagina." 10 | }, 11 | "copy_link": { 12 | "message": "Copiar enllaç al text seleccionat" 13 | }, 14 | "link_failure": { 15 | "message": "No s'ha pogut crear un enllaç únic, si us plau selecciona una sequència més llarga de paraules." 16 | }, 17 | "link_copy_style": { 18 | "message": "Estil de còpia d’enllaç" 19 | }, 20 | "rich": { 21 | "message": "Copiar un enllaç de text enriquit." 22 | }, 23 | "raw": { 24 | "message": "Copiar l'URL en brut." 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /_locales/de/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "extension_name": { 3 | "message": "Link zu Textfragment" 4 | }, 5 | "extension_short_name": { 6 | "message": "Textfragment" 7 | }, 8 | "extension_description": { 9 | "message": "Browser Extension, die das Verlinken auf beliebigen Text auf einer Seite ermöglicht." 10 | }, 11 | "copy_link": { 12 | "message": "Link zu ausgewähltem Text kopieren" 13 | }, 14 | "link_failure": { 15 | "message": "Ein eindeutiger Link konnte nicht erstellt werden, bitte eine längere Wortsequenz auswählen." 16 | }, 17 | "link_copy_style": { 18 | "message": "Kopier-Stil" 19 | }, 20 | "rich": { 21 | "message": "Kopiere Rich Text Links." 22 | }, 23 | "raw": { 24 | "message": "Kopiere rohe URLs." 25 | }, 26 | "rich_plus_raw": { 27 | "message": "Kopiere als Rich Text Link plus rohe URL." 28 | }, 29 | "link_text": { 30 | "message": "Für den 'Rich Text Link plus rohe URL' Stil, wähle oder setze das Wort für den Link-Text:" 31 | }, 32 | "link_text_option_1": { 33 | "message": "[Quelle]" 34 | }, 35 | "link_text_option_2": { 36 | "message": "[Link]" 37 | }, 38 | "link_text_option_3": { 39 | "message": "🔗" 40 | }, 41 | "link_text_option_4": { 42 | "message": "Anpassen:" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /_locales/en/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "extension_name": { 3 | "message": "Link to Text Fragment" 4 | }, 5 | "extension_short_name": { 6 | "message": "TextFragment" 7 | }, 8 | "extension_description": { 9 | "message": "Browser extension that allows for linking to arbitrary text on a page." 10 | }, 11 | "copy_link": { 12 | "message": "Copy Link to Selected Text" 13 | }, 14 | "link_failure": { 15 | "message": "Couldn't create a unique link, please select a longer sequence of words." 16 | }, 17 | "link_copy_style": { 18 | "message": "Link copy style" 19 | }, 20 | "rich": { 21 | "message": "Copy a rich text link." 22 | }, 23 | "raw": { 24 | "message": "Copy the raw URL." 25 | }, 26 | "rich_plus_raw": { 27 | "message": "Copy as rich text plus the raw URL." 28 | }, 29 | "link_text": { 30 | "message": "For the 'rich plus raw' style, select or set the word for the link:" 31 | }, 32 | "link_text_option_1": { 33 | "message": "[Source]" 34 | }, 35 | "link_text_option_2": { 36 | "message": "[Link]" 37 | }, 38 | "link_text_option_3": { 39 | "message": "🔗" 40 | }, 41 | "link_text_option_4": { 42 | "message": "Custom:" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /_locales/es/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "extension_name": { 3 | "message": "Enlace al fragmento de texto" 4 | }, 5 | "extension_short_name": { 6 | "message": "Fragmento" 7 | }, 8 | "extension_description": { 9 | "message": "Extensión del navegador, que permite enlazar a cualquier texto en una pagina." 10 | }, 11 | "copy_link": { 12 | "message": "Copiar enlace al texto seleccionado" 13 | }, 14 | "link_failure": { 15 | "message": "No se pudo crear un enlace único, por favor selecciona una secuencia de palabras más larga." 16 | }, 17 | "link_copy_style": { 18 | "message": "Estilo de copia de enlace" 19 | }, 20 | "rich": { 21 | "message": "Copiar un enlace de texto enriquecido." 22 | }, 23 | "raw": { 24 | "message": "Copie la URL cruda." 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /_locales/fr/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "extension_name": { 3 | "message": "Lien vers un fragment de texte" 4 | }, 5 | "extension_short_name": { 6 | "message": "Fragment" 7 | }, 8 | "extension_description": { 9 | "message": "Extension de navigateur qui permet de créer des liens vers du texte arbitraire sur une page." 10 | }, 11 | "copy_link": { 12 | "message": "Copier lien vers le texte sélectionné" 13 | }, 14 | "link_failure": { 15 | "message": "Impossible de créer un lien unique. Veuillez sélectionner une séquence de mots plus longue." 16 | }, 17 | "link_copy_style": { 18 | "message": "Style de copie de lien" 19 | }, 20 | "rich": { 21 | "message": "Copier un lien de texte enrichi." 22 | }, 23 | "raw": { 24 | "message": "Copier l'URL brute." 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /_locales/he/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "extension_name": { 3 | "message": "קישור לקטע טקסט" 4 | }, 5 | "extension_short_name": { 6 | "message": "קטע טקסט" 7 | }, 8 | "extension_description": { 9 | "message": "תוסף דפדפן המאפשר ליצור קישור עם הפנייה לטקסט מסוים בתוך הדף." 10 | }, 11 | "copy_link": { 12 | "message": "העתק קישור לטקסט שנבחר" 13 | }, 14 | "link_failure": { 15 | "message": "לא הצלחנו ליצור קישור, נא בחר רצף ארוך יותר של מילים." 16 | }, 17 | "link_copy_style": { 18 | "message": "סגנון העתקת קישור" 19 | }, 20 | "rich": { 21 | "message": "העתק קישור עם הפנייה למיקום בתוך המסמך." 22 | }, 23 | "raw": { 24 | "message": "העתק את כתובת האתר בלבד." 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /_locales/ko/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "extension_name": { 3 | "message": "링크 투 텍스트 프래그먼트" 4 | }, 5 | "extension_short_name": { 6 | "message": "텍스트 프래그먼트" 7 | }, 8 | "extension_description": { 9 | "message": "웹 페이지안에 임의의 텍스트로 가는 링크를 제공해주는 브라우저 확장 프로그램." 10 | }, 11 | "copy_link": { 12 | "message": "선택한 단어로 가는 링크 복사" 13 | }, 14 | "link_failure": { 15 | "message": "고유한 링크를 생성하지 못했습니다. 긴 영역의 단어들을 선택해주세요." 16 | }, 17 | "link_copy_style": { 18 | "message": "링크 복사 스타일" 19 | }, 20 | "rich": { 21 | "message": "서식있는 텍스트 링크를 복사합니다." 22 | }, 23 | "raw": { 24 | "message": "원시 URL을 복사하십시오." 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /assets/1024x1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleChromeLabs/link-to-text-fragment/11d85b83b7bc6265b621bc6f48ab52fc530573bc/assets/1024x1024.png -------------------------------------------------------------------------------- /assets/128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleChromeLabs/link-to-text-fragment/11d85b83b7bc6265b621bc6f48ab52fc530573bc/assets/128x128.png -------------------------------------------------------------------------------- /assets/16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleChromeLabs/link-to-text-fragment/11d85b83b7bc6265b621bc6f48ab52fc530573bc/assets/16x16.png -------------------------------------------------------------------------------- /assets/180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleChromeLabs/link-to-text-fragment/11d85b83b7bc6265b621bc6f48ab52fc530573bc/assets/180x180.png -------------------------------------------------------------------------------- /assets/192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleChromeLabs/link-to-text-fragment/11d85b83b7bc6265b621bc6f48ab52fc530573bc/assets/192x192.png -------------------------------------------------------------------------------- /assets/32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleChromeLabs/link-to-text-fragment/11d85b83b7bc6265b621bc6f48ab52fc530573bc/assets/32x32.png -------------------------------------------------------------------------------- /assets/512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleChromeLabs/link-to-text-fragment/11d85b83b7bc6265b621bc6f48ab52fc530573bc/assets/512x512.png -------------------------------------------------------------------------------- /assets/icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /content_script.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2020 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | ((browser) => { 17 | let DEBUG = false; 18 | 19 | const log = (...args) => { 20 | if (DEBUG) { 21 | console.log(...args); 22 | } 23 | }; 24 | 25 | const createTextFragment = () => { 26 | const selection = window.getSelection(); 27 | 28 | const result = exports.generateFragment(selection); 29 | let url = `${location.origin}${location.pathname}${location.search}${ 30 | location.hash ? location.hash : '#' 31 | }`; 32 | if (result.status === 0) { 33 | const fragment = result.fragment; 34 | const prefix = fragment.prefix 35 | ? `${encodeURIComponent(fragment.prefix)}-,` 36 | : ''; 37 | const suffix = fragment.suffix 38 | ? `,-${encodeURIComponent(fragment.suffix)}` 39 | : ''; 40 | const textStart = encodeURIComponent(fragment.textStart); 41 | const textEnd = fragment.textEnd 42 | ? `,${encodeURIComponent(fragment.textEnd)}` 43 | : ''; 44 | url = `${url}:~:text=${prefix}${textStart}${textEnd}${suffix}`; 45 | copyToClipboard(url, selection); 46 | reportSuccess(); 47 | return url; 48 | } else { 49 | reportFailure(result.status); 50 | return `Oops! Unable to create link. ${result.status}`; 51 | } 52 | }; 53 | 54 | const reportSuccess = () => { 55 | const style = document.createElement('style'); 56 | document.head.append(style); 57 | const sheet = style.sheet; 58 | sheet.insertRule(` 59 | ::selection { 60 | color: #000 !important; 61 | background-color: #ffff00 !important; 62 | }`); 63 | // Need to force re-selection for the CSS to have an effect in Safari. 64 | const selection = window.getSelection(); 65 | const range = selection.getRangeAt(0); 66 | selection.removeAllRanges(); 67 | window.setTimeout(() => selection.addRange(range), 0); 68 | window.setTimeout(() => style.remove(), 2000); 69 | return true; 70 | }; 71 | 72 | const reportFailure = (status) => { 73 | const statusCodes = { 74 | 1: 'INVALID_SELECTION: ❌ The selected text is too short or does not contain enough valid words. Please choose a longer or more specific phrase.', 75 | 2: 'AMBIGUOUS:❌ The selected text appears multiple times on this page and no unique link could be created. Try selecting a different text segment.', 76 | 3: 'TIMEOUT: ⏳ The process took too long. This may be due to a large page size or slow browser performance. Try selecting a different text segment.', 77 | 4: 'EXECUTION_FAILED: ⚠️ An unexpected error occurred while generating the link.', 78 | }; 79 | 80 | window.queueMicrotask(() => { 81 | alert( 82 | `🛑 ${browser.i18n.getMessage( 83 | 'extension_name' 84 | )}:\n${browser.i18n.getMessage('link_failure')}\n\n(${ 85 | statusCodes[status] 86 | })` 87 | ); 88 | }); 89 | return true; 90 | }; 91 | 92 | const copyToClipboard = (url, selection) => { 93 | browser.storage.sync.get( 94 | { 95 | linkStyle: 'rich', 96 | linkText: browser.i18n.getMessage('link_text_option_1'), 97 | }, 98 | async (items) => { 99 | const linkStyle = items.linkStyle; 100 | // Send message to offscreen document 101 | const selectedText = selection.toString(); 102 | const linkText = items.linkText; 103 | let html = ''; 104 | if (selection.rangeCount) { 105 | const container = document.createElement('div'); 106 | for (let i = 0, len = selection.rangeCount; i < len; ++i) { 107 | // prettier-ignore 108 | container.appendChild( 109 | selection.getRangeAt(i).cloneContents()); 110 | } 111 | html = container.innerHTML; 112 | } 113 | browser.runtime.sendMessage( 114 | { 115 | target: 'offscreen', 116 | data: { linkStyle, url, selectedText, html, linkText }, 117 | }, 118 | (response) => { 119 | if (response) { 120 | log('🎉', url); 121 | } 122 | } 123 | ); 124 | } 125 | ); 126 | }; 127 | 128 | browser.runtime.onMessage.addListener((request, _, sendResponse) => { 129 | const message = request.message; 130 | if (message === 'create-text-fragment') { 131 | return sendResponse(createTextFragment()); 132 | } else if (message === 'debug') { 133 | return sendResponse((DEBUG = request.data) || true); 134 | } else if (message === 'ping') { 135 | return sendResponse('pong'); 136 | } 137 | }); 138 | })(chrome || browser); 139 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | export default [ 2 | { 3 | ignores: ['fragment-generation-utils.js'], 4 | }, 5 | ]; 6 | -------------------------------------------------------------------------------- /fragment-generation-utils.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | exports.setTimeout = exports.isValidRangeForFragmentGeneration = exports.generateFragmentFromRange = exports.generateFragment = exports.forTesting = exports.GenerateFragmentStatus = void 0; 7 | /** 8 | * Copyright 2020 Google LLC 9 | * 10 | * Licensed under the Apache License, Version 2.0 (the "License"); 11 | * you may not use this file except in compliance with the License. 12 | * You may obtain a copy of the License at 13 | * 14 | * https://www.apache.org/licenses/LICENSE-2.0 15 | * 16 | * Unless required by applicable law or agreed to in writing, software 17 | * distributed under the License is distributed on an "AS IS" BASIS, 18 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 19 | * See the License for the specific language governing permissions and 20 | * limitations under the License. 21 | */ 22 | 23 | // Block elements. elements of a text fragment cannot cross the boundaries of a 24 | // block element. Source for the list: 25 | // https://developer.mozilla.org/en-US/docs/Web/HTML/Block-level_elements#Elements 26 | const BLOCK_ELEMENTS = ['ADDRESS', 'ARTICLE', 'ASIDE', 'BLOCKQUOTE', 'BR', 'DETAILS', 'DIALOG', 'DD', 'DIV', 'DL', 'DT', 'FIELDSET', 'FIGCAPTION', 'FIGURE', 'FOOTER', 'FORM', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'HEADER', 'HGROUP', 'HR', 'LI', 'MAIN', 'NAV', 'OL', 'P', 'PRE', 'SECTION', 'TABLE', 'UL', 'TR', 'TH', 'TD', 'COLGROUP', 'COL', 'CAPTION', 'THEAD', 'TBODY', 'TFOOT']; 27 | 28 | // Characters that indicate a word boundary. Use the script 29 | // tools/generate-boundary-regex.js if it's necessary to modify or regenerate 30 | // this. Because it's a hefty regex, this should be used infrequently and only 31 | // on single-character strings. 32 | const BOUNDARY_CHARS = /[\t-\r -#%-\*,-\/:;\?@\[-\]_\{\}\x85\xA0\xA1\xA7\xAB\xB6\xB7\xBB\xBF\u037E\u0387\u055A-\u055F\u0589\u058A\u05BE\u05C0\u05C3\u05C6\u05F3\u05F4\u0609\u060A\u060C\u060D\u061B\u061E\u061F\u066A-\u066D\u06D4\u0700-\u070D\u07F7-\u07F9\u0830-\u083E\u085E\u0964\u0965\u0970\u0AF0\u0DF4\u0E4F\u0E5A\u0E5B\u0F04-\u0F12\u0F14\u0F3A-\u0F3D\u0F85\u0FD0-\u0FD4\u0FD9\u0FDA\u104A-\u104F\u10FB\u1360-\u1368\u1400\u166D\u166E\u1680\u169B\u169C\u16EB-\u16ED\u1735\u1736\u17D4-\u17D6\u17D8-\u17DA\u1800-\u180A\u1944\u1945\u1A1E\u1A1F\u1AA0-\u1AA6\u1AA8-\u1AAD\u1B5A-\u1B60\u1BFC-\u1BFF\u1C3B-\u1C3F\u1C7E\u1C7F\u1CC0-\u1CC7\u1CD3\u2000-\u200A\u2010-\u2029\u202F-\u2043\u2045-\u2051\u2053-\u205F\u207D\u207E\u208D\u208E\u2308-\u230B\u2329\u232A\u2768-\u2775\u27C5\u27C6\u27E6-\u27EF\u2983-\u2998\u29D8-\u29DB\u29FC\u29FD\u2CF9-\u2CFC\u2CFE\u2CFF\u2D70\u2E00-\u2E2E\u2E30-\u2E44\u3000-\u3003\u3008-\u3011\u3014-\u301F\u3030\u303D\u30A0\u30FB\uA4FE\uA4FF\uA60D-\uA60F\uA673\uA67E\uA6F2-\uA6F7\uA874-\uA877\uA8CE\uA8CF\uA8F8-\uA8FA\uA8FC\uA92E\uA92F\uA95F\uA9C1-\uA9CD\uA9DE\uA9DF\uAA5C-\uAA5F\uAADE\uAADF\uAAF0\uAAF1\uABEB\uFD3E\uFD3F\uFE10-\uFE19\uFE30-\uFE52\uFE54-\uFE61\uFE63\uFE68\uFE6A\uFE6B\uFF01-\uFF03\uFF05-\uFF0A\uFF0C-\uFF0F\uFF1A\uFF1B\uFF1F\uFF20\uFF3B-\uFF3D\uFF3F\uFF5B\uFF5D\uFF5F-\uFF65]|\uD800[\uDD00-\uDD02\uDF9F\uDFD0]|\uD801\uDD6F|\uD802[\uDC57\uDD1F\uDD3F\uDE50-\uDE58\uDE7F\uDEF0-\uDEF6\uDF39-\uDF3F\uDF99-\uDF9C]|\uD804[\uDC47-\uDC4D\uDCBB\uDCBC\uDCBE-\uDCC1\uDD40-\uDD43\uDD74\uDD75\uDDC5-\uDDC9\uDDCD\uDDDB\uDDDD-\uDDDF\uDE38-\uDE3D\uDEA9]|\uD805[\uDC4B-\uDC4F\uDC5B\uDC5D\uDCC6\uDDC1-\uDDD7\uDE41-\uDE43\uDE60-\uDE6C\uDF3C-\uDF3E]|\uD807[\uDC41-\uDC45\uDC70\uDC71]|\uD809[\uDC70-\uDC74]|\uD81A[\uDE6E\uDE6F\uDEF5\uDF37-\uDF3B\uDF44]|\uD82F\uDC9F|\uD836[\uDE87-\uDE8B]|\uD83A[\uDD5E\uDD5F]/u; 33 | 34 | // The same thing, but with a ^. 35 | const NON_BOUNDARY_CHARS = /[^\t-\r -#%-\*,-\/:;\?@\[-\]_\{\}\x85\xA0\xA1\xA7\xAB\xB6\xB7\xBB\xBF\u037E\u0387\u055A-\u055F\u0589\u058A\u05BE\u05C0\u05C3\u05C6\u05F3\u05F4\u0609\u060A\u060C\u060D\u061B\u061E\u061F\u066A-\u066D\u06D4\u0700-\u070D\u07F7-\u07F9\u0830-\u083E\u085E\u0964\u0965\u0970\u0AF0\u0DF4\u0E4F\u0E5A\u0E5B\u0F04-\u0F12\u0F14\u0F3A-\u0F3D\u0F85\u0FD0-\u0FD4\u0FD9\u0FDA\u104A-\u104F\u10FB\u1360-\u1368\u1400\u166D\u166E\u1680\u169B\u169C\u16EB-\u16ED\u1735\u1736\u17D4-\u17D6\u17D8-\u17DA\u1800-\u180A\u1944\u1945\u1A1E\u1A1F\u1AA0-\u1AA6\u1AA8-\u1AAD\u1B5A-\u1B60\u1BFC-\u1BFF\u1C3B-\u1C3F\u1C7E\u1C7F\u1CC0-\u1CC7\u1CD3\u2000-\u200A\u2010-\u2029\u202F-\u2043\u2045-\u2051\u2053-\u205F\u207D\u207E\u208D\u208E\u2308-\u230B\u2329\u232A\u2768-\u2775\u27C5\u27C6\u27E6-\u27EF\u2983-\u2998\u29D8-\u29DB\u29FC\u29FD\u2CF9-\u2CFC\u2CFE\u2CFF\u2D70\u2E00-\u2E2E\u2E30-\u2E44\u3000-\u3003\u3008-\u3011\u3014-\u301F\u3030\u303D\u30A0\u30FB\uA4FE\uA4FF\uA60D-\uA60F\uA673\uA67E\uA6F2-\uA6F7\uA874-\uA877\uA8CE\uA8CF\uA8F8-\uA8FA\uA8FC\uA92E\uA92F\uA95F\uA9C1-\uA9CD\uA9DE\uA9DF\uAA5C-\uAA5F\uAADE\uAADF\uAAF0\uAAF1\uABEB\uFD3E\uFD3F\uFE10-\uFE19\uFE30-\uFE52\uFE54-\uFE61\uFE63\uFE68\uFE6A\uFE6B\uFF01-\uFF03\uFF05-\uFF0A\uFF0C-\uFF0F\uFF1A\uFF1B\uFF1F\uFF20\uFF3B-\uFF3D\uFF3F\uFF5B\uFF5D\uFF5F-\uFF65]|\uD800[\uDD00-\uDD02\uDF9F\uDFD0]|\uD801\uDD6F|\uD802[\uDC57\uDD1F\uDD3F\uDE50-\uDE58\uDE7F\uDEF0-\uDEF6\uDF39-\uDF3F\uDF99-\uDF9C]|\uD804[\uDC47-\uDC4D\uDCBB\uDCBC\uDCBE-\uDCC1\uDD40-\uDD43\uDD74\uDD75\uDDC5-\uDDC9\uDDCD\uDDDB\uDDDD-\uDDDF\uDE38-\uDE3D\uDEA9]|\uD805[\uDC4B-\uDC4F\uDC5B\uDC5D\uDCC6\uDDC1-\uDDD7\uDE41-\uDE43\uDE60-\uDE6C\uDF3C-\uDF3E]|\uD807[\uDC41-\uDC45\uDC70\uDC71]|\uD809[\uDC70-\uDC74]|\uD81A[\uDE6E\uDE6F\uDEF5\uDF37-\uDF3B\uDF44]|\uD82F\uDC9F|\uD836[\uDE87-\uDE8B]|\uD83A[\uDD5E\uDD5F]/u; 36 | 37 | /** 38 | * Searches the document for a given text fragment. 39 | * 40 | * @param {TextFragment} textFragment - Text Fragment to highlight. 41 | * @param {Document} documentToProcess - document where to extract and mark 42 | * fragments in. 43 | * @return {Ranges[]} - Zero or more ranges within the document corresponding 44 | * to the fragment. If the fragment corresponds to more than one location 45 | * in the document (i.e., is ambiguous) then the first two matches will be 46 | * returned (regardless of how many more matches there may be in the 47 | * document). 48 | */ 49 | 50 | const processTextFragmentDirective = (textFragment, documentToProcess = document) => { 51 | const results = []; 52 | const searchRange = documentToProcess.createRange(); 53 | searchRange.selectNodeContents(documentToProcess.body); 54 | while (!searchRange.collapsed && results.length < 2) { 55 | let potentialMatch; 56 | if (textFragment.prefix) { 57 | const prefixMatch = findTextInRange(textFragment.prefix, searchRange); 58 | if (prefixMatch == null) { 59 | break; 60 | } 61 | // Future iterations, if necessary, should start after the first 62 | // character of the prefix match. 63 | advanceRangeStartPastOffset(searchRange, prefixMatch.startContainer, prefixMatch.startOffset); 64 | 65 | // The search space for textStart is everything after the prefix and 66 | // before the end of the top-level search range, starting at the next 67 | // non- whitespace position. 68 | const matchRange = documentToProcess.createRange(); 69 | matchRange.setStart(prefixMatch.endContainer, prefixMatch.endOffset); 70 | matchRange.setEnd(searchRange.endContainer, searchRange.endOffset); 71 | advanceRangeStartToNonWhitespace(matchRange); 72 | if (matchRange.collapsed) { 73 | break; 74 | } 75 | potentialMatch = findTextInRange(textFragment.textStart, matchRange); 76 | // If textStart wasn't found anywhere in the matchRange, then there's 77 | // no possible match and we can stop early. 78 | if (potentialMatch == null) { 79 | break; 80 | } 81 | 82 | // If potentialMatch is immediately after the prefix (i.e., its start 83 | // equals matchRange's start), this is a candidate and we should keep 84 | // going with this iteration. Otherwise, we'll need to find the next 85 | // instance (if any) of the prefix. 86 | if (potentialMatch.compareBoundaryPoints(Range.START_TO_START, matchRange) !== 0) { 87 | continue; 88 | } 89 | } else { 90 | // With no prefix, just look directly for textStart. 91 | potentialMatch = findTextInRange(textFragment.textStart, searchRange); 92 | if (potentialMatch == null) { 93 | break; 94 | } 95 | advanceRangeStartPastOffset(searchRange, potentialMatch.startContainer, potentialMatch.startOffset); 96 | } 97 | if (textFragment.textEnd) { 98 | const textEndRange = documentToProcess.createRange(); 99 | textEndRange.setStart(potentialMatch.endContainer, potentialMatch.endOffset); 100 | textEndRange.setEnd(searchRange.endContainer, searchRange.endOffset); 101 | 102 | // Keep track of matches of the end term followed by suffix term 103 | // (if needed). 104 | // If no matches are found then there's no point in keeping looking 105 | // for matches of the start term after the current start term 106 | // occurrence. 107 | let matchFound = false; 108 | 109 | // Search through the rest of the document to find a textEnd match. 110 | // This may take multiple iterations if a suffix needs to be found. 111 | while (!textEndRange.collapsed && results.length < 2) { 112 | const textEndMatch = findTextInRange(textFragment.textEnd, textEndRange); 113 | if (textEndMatch == null) { 114 | break; 115 | } 116 | advanceRangeStartPastOffset(textEndRange, textEndMatch.startContainer, textEndMatch.startOffset); 117 | potentialMatch.setEnd(textEndMatch.endContainer, textEndMatch.endOffset); 118 | if (textFragment.suffix) { 119 | // If there's supposed to be a suffix, check if it appears after 120 | // the textEnd we just found. 121 | const suffixResult = checkSuffix(textFragment.suffix, potentialMatch, searchRange, documentToProcess); 122 | if (suffixResult === CheckSuffixResult.NO_SUFFIX_MATCH) { 123 | break; 124 | } else if (suffixResult === CheckSuffixResult.SUFFIX_MATCH) { 125 | matchFound = true; 126 | results.push(potentialMatch.cloneRange()); 127 | continue; 128 | } else if (suffixResult === CheckSuffixResult.MISPLACED_SUFFIX) { 129 | continue; 130 | } 131 | } else { 132 | // If we've found textEnd and there's no suffix, then it's a 133 | // match! 134 | matchFound = true; 135 | results.push(potentialMatch.cloneRange()); 136 | } 137 | } 138 | // Stopping match search because suffix or textEnd are missing from 139 | // the rest of the search space. 140 | if (!matchFound) { 141 | break; 142 | } 143 | } else if (textFragment.suffix) { 144 | // If there's no textEnd but there is a suffix, search for the suffix 145 | // after potentialMatch 146 | const suffixResult = checkSuffix(textFragment.suffix, potentialMatch, searchRange, documentToProcess); 147 | if (suffixResult === CheckSuffixResult.NO_SUFFIX_MATCH) { 148 | break; 149 | } else if (suffixResult === CheckSuffixResult.SUFFIX_MATCH) { 150 | results.push(potentialMatch.cloneRange()); 151 | advanceRangeStartPastOffset(searchRange, searchRange.startContainer, searchRange.startOffset); 152 | continue; 153 | } else if (suffixResult === CheckSuffixResult.MISPLACED_SUFFIX) { 154 | continue; 155 | } 156 | } else { 157 | results.push(potentialMatch.cloneRange()); 158 | } 159 | } 160 | return results; 161 | }; 162 | 163 | /** 164 | * Enum indicating the result of the checkSuffix function. 165 | */ 166 | const CheckSuffixResult = { 167 | NO_SUFFIX_MATCH: 0, 168 | // Suffix wasn't found at all. Search should halt. 169 | SUFFIX_MATCH: 1, 170 | // The suffix matches the expectation. 171 | MISPLACED_SUFFIX: 2 // The suffix was found, but not in the right place. 172 | }; 173 | 174 | /** 175 | * Checks to see if potentialMatch satisfies the suffix conditions of this 176 | * Text Fragment. 177 | * @param {String} suffix - the suffix text to find 178 | * @param {Range} potentialMatch - the Range containing the match text. 179 | * @param {Range} searchRange - the Range in which to search for |suffix|. 180 | * Regardless of the start boundary of this Range, nothing appearing before 181 | * |potentialMatch| will be considered. 182 | * @param {Document} documentToProcess - document where to extract and mark 183 | * fragments in. 184 | * @return {CheckSuffixResult} - enum value indicating that potentialMatch 185 | * should be accepted, that the search should continue, or that the search 186 | * should halt. 187 | */ 188 | const checkSuffix = (suffix, potentialMatch, searchRange, documentToProcess) => { 189 | const suffixRange = documentToProcess.createRange(); 190 | suffixRange.setStart(potentialMatch.endContainer, potentialMatch.endOffset); 191 | suffixRange.setEnd(searchRange.endContainer, searchRange.endOffset); 192 | advanceRangeStartToNonWhitespace(suffixRange); 193 | const suffixMatch = findTextInRange(suffix, suffixRange); 194 | // If suffix wasn't found anywhere in the suffixRange, then there's no 195 | // possible match and we can stop early. 196 | if (suffixMatch == null) { 197 | return CheckSuffixResult.NO_SUFFIX_MATCH; 198 | } 199 | 200 | // If suffixMatch is immediately after potentialMatch (i.e., its start 201 | // equals suffixRange's start), this is a match. If not, we have to 202 | // start over from the beginning. 203 | if (suffixMatch.compareBoundaryPoints(Range.START_TO_START, suffixRange) !== 0) { 204 | return CheckSuffixResult.MISPLACED_SUFFIX; 205 | } 206 | return CheckSuffixResult.SUFFIX_MATCH; 207 | }; 208 | 209 | /** 210 | * Sets the start of |range| to be the first boundary point after |offset| in 211 | * |node|--either at offset+1, or after the node. 212 | * @param {Range} range - the range to mutate 213 | * @param {Node} node - the node used to determine the new range start 214 | * @param {Number} offset - the offset immediately before the desired new 215 | * boundary point 216 | */ 217 | const advanceRangeStartPastOffset = (range, node, offset) => { 218 | try { 219 | range.setStart(node, offset + 1); 220 | } catch (err) { 221 | range.setStartAfter(node); 222 | } 223 | }; 224 | 225 | /** 226 | * Modifies |range| to start at the next non-whitespace position. 227 | * @param {Range} range - the range to mutate 228 | */ 229 | const advanceRangeStartToNonWhitespace = range => { 230 | const walker = makeTextNodeWalker(range); 231 | let node = walker.nextNode(); 232 | while (!range.collapsed && node != null) { 233 | if (node !== range.startContainer) { 234 | range.setStart(node, 0); 235 | } 236 | if (node.textContent.length > range.startOffset) { 237 | const firstChar = node.textContent[range.startOffset]; 238 | if (!firstChar.match(/\s/)) { 239 | return; 240 | } 241 | } 242 | try { 243 | range.setStart(node, range.startOffset + 1); 244 | } catch (err) { 245 | node = walker.nextNode(); 246 | if (node == null) { 247 | range.collapse(); 248 | } else { 249 | range.setStart(node, 0); 250 | } 251 | } 252 | } 253 | }; 254 | 255 | /** 256 | * Creates a TreeWalker that traverses a range and emits visible text nodes in 257 | * the range. 258 | * @param {Range} range - Range to be traversed by the walker 259 | * @return {TreeWalker} 260 | */ 261 | const makeTextNodeWalker = range => { 262 | const walker = document.createTreeWalker(range.commonAncestorContainer, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT, node => { 263 | return acceptTextNodeIfVisibleInRange(node, range); 264 | }); 265 | return walker; 266 | }; 267 | 268 | /** 269 | * Helper function to check if the element has attribute `hidden="until-found"`. 270 | * @param {Element} node - the element to evaluate 271 | * @return {Boolean} - true if the element has attribute `hidden="until-found"` 272 | */ 273 | const isHiddenUntilFound = elt => { 274 | if (elt.hidden === 'until-found') { 275 | return true; 276 | } 277 | // Workaround for WebKit. See https://bugs.webkit.org/show_bug.cgi?id=238266 278 | const attributes = elt.attributes; 279 | if (attributes && attributes['hidden']) { 280 | const value = attributes['hidden'].value; 281 | if (value === 'until-found') { 282 | return true; 283 | } 284 | } 285 | return false; 286 | }; 287 | 288 | /** 289 | * Helper function to calculate the visibility of a Node based on its CSS 290 | * computed style. This function does not take into account the visibility of 291 | * the node's ancestors so even if the node is visible according to its style 292 | * it might not be visible on the page if one of its ancestors is not visible. 293 | * @param {Node} node - the Node to evaluate 294 | * @return {Boolean} - true if the node is visible. A node will be visible if 295 | * its computed style meets all of the following criteria: 296 | * - non zero height, width, height and opacity 297 | * - visibility not hidden 298 | * - display not none 299 | */ 300 | const isNodeVisible = node => { 301 | // Find an HTMLElement (this node or an ancestor) so we can check 302 | // visibility. 303 | let elt = node; 304 | while (elt != null && !(elt instanceof HTMLElement)) elt = elt.parentNode; 305 | if (elt != null) { 306 | if (isHiddenUntilFound(elt)) { 307 | return true; 308 | } 309 | const nodeStyle = window.getComputedStyle(elt); 310 | // If the node is not rendered, just skip it. 311 | if (nodeStyle.visibility === 'hidden' || nodeStyle.display === 'none' || parseInt(nodeStyle.height, 10) === 0 || parseInt(nodeStyle.width, 10) === 0 || parseInt(nodeStyle.opacity, 10) === 0) { 312 | return false; 313 | } 314 | } 315 | return true; 316 | }; 317 | 318 | /** 319 | * Filter function for use with TreeWalkers. Rejects nodes that aren't in the 320 | * given range or aren't visible. 321 | * @param {Node} node - the Node to evaluate 322 | * @param {Range|Undefined} range - the range in which node must fall. Optional; 323 | * if null, the range check is skipped. 324 | * @return {NodeFilter} - FILTER_ACCEPT or FILTER_REJECT, to be passed along to 325 | * a TreeWalker. 326 | */ 327 | const acceptNodeIfVisibleInRange = (node, range) => { 328 | if (range != null && !range.intersectsNode(node)) return NodeFilter.FILTER_REJECT; 329 | return isNodeVisible(node) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT; 330 | }; 331 | 332 | /** 333 | * Filter function for use with TreeWalkers. Accepts only visible text nodes 334 | * that are in the given range. Other types of nodes visible in the given range 335 | * are skipped so a TreeWalker using this filter function still visits text 336 | * nodes in the node's subtree. 337 | * @param {Node} node - the Node to evaluate 338 | * @param {Range} range - the range in which node must fall. Optional; 339 | * if null, the range check is skipped/ 340 | * @return {NodeFilter} - NodeFilter value to be passed along to a TreeWalker. 341 | * Values returned: 342 | * - FILTER_REJECT: Node not in range or not visible. 343 | * - FILTER_SKIP: Non Text Node visible and in range 344 | * - FILTER_ACCEPT: Text Node visible and in range 345 | */ 346 | const acceptTextNodeIfVisibleInRange = (node, range) => { 347 | if (range != null && !range.intersectsNode(node)) return NodeFilter.FILTER_REJECT; 348 | if (!isNodeVisible(node)) { 349 | return NodeFilter.FILTER_REJECT; 350 | } 351 | return node.nodeType === Node.TEXT_NODE ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP; 352 | }; 353 | 354 | /** 355 | * Extracts all the text nodes within the given range. 356 | * @param {Node} root - the root node in which to search 357 | * @param {Range} range - a range restricting the scope of extraction 358 | * @return {Array} - a list of lists of text nodes, in document order. 359 | * Lists represent block boundaries; i.e., two nodes appear in the same list 360 | * iff there are no block element starts or ends in between them. 361 | */ 362 | const getAllTextNodes = (root, range) => { 363 | const blocks = []; 364 | let tmp = []; 365 | const nodes = Array.from(getElementsIn(root, node => { 366 | return acceptNodeIfVisibleInRange(node, range); 367 | })); 368 | for (const node of nodes) { 369 | if (node.nodeType === Node.TEXT_NODE) { 370 | tmp.push(node); 371 | } else if (node instanceof HTMLElement && BLOCK_ELEMENTS.includes(node.tagName.toUpperCase()) && tmp.length > 0) { 372 | // If this is a block element, the current set of text nodes in |tmp| is 373 | // complete, and we need to move on to a new one. 374 | blocks.push(tmp); 375 | tmp = []; 376 | } 377 | } 378 | if (tmp.length > 0) blocks.push(tmp); 379 | return blocks; 380 | }; 381 | 382 | /** 383 | * Returns the textContent of all the textNodes and normalizes strings by 384 | * replacing duplicated spaces with single space. 385 | * @param {Node[]} nodes - TextNodes to get the textContent from. 386 | * @param {Number} startOffset - Where to start in the first TextNode. 387 | * @param {Number|undefined} endOffset Where to end in the last TextNode. 388 | * @return {string} Entire text content of all the nodes, with spaces 389 | * normalized. 390 | */ 391 | const getTextContent = (nodes, startOffset, endOffset) => { 392 | let str = ''; 393 | if (nodes.length === 1) { 394 | str = nodes[0].textContent.substring(startOffset, endOffset); 395 | } else { 396 | str = nodes[0].textContent.substring(startOffset) + nodes.slice(1, -1).reduce((s, n) => s + n.textContent, '') + nodes.slice(-1)[0].textContent.substring(0, endOffset); 397 | } 398 | return str.replace(/[\t\n\r ]+/g, ' '); 399 | }; 400 | 401 | /** 402 | * @callback ElementFilterFunction 403 | * @param {HTMLElement} element - Node to accept, reject or skip. 404 | * @return {number} Either NodeFilter.FILTER_ACCEPT, NodeFilter.FILTER_REJECT 405 | * or NodeFilter.FILTER_SKIP. 406 | */ 407 | 408 | /** 409 | * Returns all nodes inside root using the provided filter. 410 | * @generator 411 | * @param {Node} root - Node where to start the TreeWalker. 412 | * @param {ElementFilterFunction} filter - Filter provided to the TreeWalker's 413 | * acceptNode filter. 414 | * @yield {HTMLElement} All elements that were accepted by filter. 415 | */ 416 | function* getElementsIn(root, filter) { 417 | const treeWalker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT, { 418 | acceptNode: filter 419 | }); 420 | const finishedSubtrees = new Set(); 421 | while (forwardTraverse(treeWalker, finishedSubtrees) !== null) { 422 | yield treeWalker.currentNode; 423 | } 424 | } 425 | 426 | /** 427 | * Returns a range pointing to the first instance of |query| within |range|. 428 | * @param {String} query - the string to find 429 | * @param {Range} range - the range in which to search 430 | * @return {Range|Undefined} - The first found instance of |query| within 431 | * |range|. 432 | */ 433 | const findTextInRange = (query, range) => { 434 | const textNodeLists = getAllTextNodes(range.commonAncestorContainer, range); 435 | const segmenter = makeNewSegmenter(); 436 | for (const list of textNodeLists) { 437 | const found = findRangeFromNodeList(query, range, list, segmenter); 438 | if (found !== undefined) return found; 439 | } 440 | return undefined; 441 | }; 442 | 443 | /** 444 | * Finds a range pointing to the first instance of |query| within |range|, 445 | * searching over the text contained in a list |nodeList| of relevant textNodes. 446 | * @param {String} query - the string to find 447 | * @param {Range} range - the range in which to search 448 | * @param {Node[]} textNodes - the visible text nodes within |range| 449 | * @param {Intl.Segmenter} [segmenter] - a segmenter to be used for finding word 450 | * boundaries, if supported 451 | * @return {Range} - the found range, or undefined if no such range could be 452 | * found 453 | */ 454 | const findRangeFromNodeList = (query, range, textNodes, segmenter) => { 455 | if (!query || !range || !(textNodes || []).length) return undefined; 456 | const data = normalizeString(getTextContent(textNodes, 0, undefined)); 457 | const normalizedQuery = normalizeString(query); 458 | let searchStart = textNodes[0] === range.startContainer ? range.startOffset : 0; 459 | let start; 460 | let end; 461 | while (searchStart < data.length) { 462 | const matchIndex = data.indexOf(normalizedQuery, searchStart); 463 | if (matchIndex === -1) return undefined; 464 | if (isWordBounded(data, matchIndex, normalizedQuery.length, segmenter)) { 465 | start = getBoundaryPointAtIndex(matchIndex, textNodes, /* isEnd=*/false); 466 | end = getBoundaryPointAtIndex(matchIndex + normalizedQuery.length, textNodes, /* isEnd=*/true); 467 | } 468 | if (start != null && end != null) { 469 | const foundRange = new Range(); 470 | foundRange.setStart(start.node, start.offset); 471 | foundRange.setEnd(end.node, end.offset); 472 | 473 | // Verify that |foundRange| is a subrange of |range| 474 | if (range.compareBoundaryPoints(Range.START_TO_START, foundRange) <= 0 && range.compareBoundaryPoints(Range.END_TO_END, foundRange) >= 0) { 475 | return foundRange; 476 | } 477 | } 478 | searchStart = matchIndex + 1; 479 | } 480 | return undefined; 481 | }; 482 | 483 | /** 484 | * Provides the data needed for calling setStart/setEnd on a Range. 485 | * @typedef {Object} BoundaryPoint 486 | * @property {Node} node 487 | * @property {Number} offset 488 | */ 489 | 490 | /** 491 | * Generates a boundary point pointing to the given text position. 492 | * @param {Number} index - the text offset indicating the start/end of a 493 | * substring of the concatenated, normalized text in |textNodes| 494 | * @param {Node[]} textNodes - the text Nodes whose contents make up the search 495 | * space 496 | * @param {bool} isEnd - indicates whether the offset is the start or end of the 497 | * substring 498 | * @return {BoundaryPoint} - a boundary point suitable for setting as the start 499 | * or end of a Range, or undefined if it couldn't be computed. 500 | */ 501 | const getBoundaryPointAtIndex = (index, textNodes, isEnd) => { 502 | let counted = 0; 503 | let normalizedData; 504 | for (let i = 0; i < textNodes.length; i++) { 505 | const node = textNodes[i]; 506 | if (!normalizedData) normalizedData = normalizeString(node.data); 507 | let nodeEnd = counted + normalizedData.length; 508 | if (isEnd) nodeEnd += 1; 509 | if (nodeEnd > index) { 510 | // |index| falls within this node, but we need to turn the offset in the 511 | // normalized data into an offset in the real node data. 512 | const normalizedOffset = index - counted; 513 | let denormalizedOffset = Math.min(index - counted, node.data.length); 514 | 515 | // Walk through the string until denormalizedOffset produces a substring 516 | // that corresponds to the target from the normalized data. 517 | const targetSubstring = isEnd ? normalizedData.substring(0, normalizedOffset) : normalizedData.substring(normalizedOffset); 518 | let candidateSubstring = isEnd ? normalizeString(node.data.substring(0, denormalizedOffset)) : normalizeString(node.data.substring(denormalizedOffset)); 519 | 520 | // We will either lengthen or shrink the candidate string to approach the 521 | // length of the target string. If we're looking for the start, adding 1 522 | // makes the candidate shorter; if we're looking for the end, it makes the 523 | // candidate longer. 524 | const direction = (isEnd ? -1 : 1) * (targetSubstring.length > candidateSubstring.length ? -1 : 1); 525 | while (denormalizedOffset >= 0 && denormalizedOffset <= node.data.length) { 526 | if (candidateSubstring.length === targetSubstring.length) { 527 | return { 528 | node: node, 529 | offset: denormalizedOffset 530 | }; 531 | } 532 | denormalizedOffset += direction; 533 | candidateSubstring = isEnd ? normalizeString(node.data.substring(0, denormalizedOffset)) : normalizeString(node.data.substring(denormalizedOffset)); 534 | } 535 | } 536 | counted += normalizedData.length; 537 | if (i + 1 < textNodes.length) { 538 | // Edge case: if this node ends with a whitespace character and the next 539 | // node starts with one, they'll be double-counted relative to the 540 | // normalized version. Subtract 1 from |counted| to compensate. 541 | const nextNormalizedData = normalizeString(textNodes[i + 1].data); 542 | if (normalizedData.slice(-1) === ' ' && nextNormalizedData.slice(0, 1) === ' ') { 543 | counted -= 1; 544 | } 545 | // Since we already normalized the next node's data, hold on to it for the 546 | // next iteration. 547 | normalizedData = nextNormalizedData; 548 | } 549 | } 550 | return undefined; 551 | }; 552 | 553 | /** 554 | * Checks if a substring is word-bounded in the context of a longer string. 555 | * 556 | * If an Intl.Segmenter is provided for locale-specific segmenting, it will be 557 | * used for this check. This is the most desirable option, but not supported in 558 | * all browsers. 559 | * 560 | * If one is not provided, a heuristic will be applied, 561 | * returning true iff: 562 | * - startPos == 0 OR char before start is a boundary char, AND 563 | * - length indicates end of string OR char after end is a boundary char 564 | * Where boundary chars are whitespace/punctuation defined in the const above. 565 | * This causes the known issue that some languages, notably Japanese, only match 566 | * at the level of roughly a full clause or sentence, rather than a word. 567 | * 568 | * @param {String} text - the text to search 569 | * @param {Number} startPos - the index of the start of the substring 570 | * @param {Number} length - the length of the substring 571 | * @param {Intl.Segmenter} [segmenter] - a segmenter to be used for finding word 572 | * boundaries, if supported 573 | * @return {bool} - true iff startPos and length point to a word-bounded 574 | * substring of |text|. 575 | */ 576 | const isWordBounded = (text, startPos, length, segmenter) => { 577 | if (startPos < 0 || startPos >= text.length || length <= 0 || startPos + length > text.length) { 578 | return false; 579 | } 580 | if (segmenter) { 581 | // If the Intl.Segmenter API is available on this client, use it for more 582 | // reliable word boundary checking. 583 | 584 | const segments = segmenter.segment(text); 585 | const startSegment = segments.containing(startPos); 586 | if (!startSegment) return false; 587 | // If the start index is inside a word segment but not the first character 588 | // in that segment, it's not word-bounded. If it's not a word segment, then 589 | // it's punctuation, etc., so that counts for word bounding. 590 | if (startSegment.isWordLike && startSegment.index != startPos) return false; 591 | 592 | // |endPos| points to the first character outside the target substring. 593 | const endPos = startPos + length; 594 | const endSegment = segments.containing(endPos); 595 | 596 | // If there's no end segment found, it's because we're at the end of the 597 | // text, which is a valid boundary. (Because of the preconditions we 598 | // checked above, we know we aren't out of range.) 599 | // If there's an end segment found but it's non-word-like, that's also OK, 600 | // since punctuation and whitespace are acceptable boundaries. 601 | // Lastly, if there's an end segment and it is word-like, then |endPos| 602 | // needs to point to the start of that new word, or |endSegment.index|. 603 | if (endSegment && endSegment.isWordLike && endSegment.index != endPos) return false; 604 | } else { 605 | // We don't have Intl.Segmenter support, so fall back to checking whether or 606 | // not the substring is flanked by boundary characters. 607 | 608 | // If the first character is already a boundary, move it once. 609 | if (text[startPos].match(BOUNDARY_CHARS)) { 610 | ++startPos; 611 | --length; 612 | if (!length) { 613 | return false; 614 | } 615 | } 616 | 617 | // If the last character is already a boundary, move it once. 618 | if (text[startPos + length - 1].match(BOUNDARY_CHARS)) { 619 | --length; 620 | if (!length) { 621 | return false; 622 | } 623 | } 624 | if (startPos !== 0 && !text[startPos - 1].match(BOUNDARY_CHARS)) return false; 625 | if (startPos + length !== text.length && !text[startPos + length].match(BOUNDARY_CHARS)) return false; 626 | } 627 | return true; 628 | }; 629 | 630 | /** 631 | * @param {String} str - a string to be normalized 632 | * @return {String} - a normalized version of |str| with all consecutive 633 | * whitespace chars converted to a single ' ' and all diacriticals removed 634 | * (e.g., 'é' -> 'e'). 635 | */ 636 | const normalizeString = str => { 637 | // First, decompose any characters with diacriticals. Then, turn all 638 | // consecutive whitespace characters into a standard " ", and strip out 639 | // anything in the Unicode U+0300..U+036F (Combining Diacritical Marks) range. 640 | // This may change the length of the string. 641 | return (str || '').normalize('NFKD').replace(/\s+/g, ' ').replace(/[\u0300-\u036f]/g, '').toLowerCase(); 642 | }; 643 | 644 | /** 645 | * @return {Intl.Segmenter|undefined} - a segmenter object suitable for finding 646 | * word boundaries. Returns undefined on browsers/platforms that do not yet 647 | * support the Intl.Segmenter API. 648 | */ 649 | const makeNewSegmenter = () => { 650 | if (Intl.Segmenter) { 651 | let lang = document.documentElement.lang; 652 | if (!lang) { 653 | lang = navigator.languages; 654 | } 655 | return new Intl.Segmenter(lang, { 656 | granularity: 'word' 657 | }); 658 | } 659 | return undefined; 660 | }; 661 | 662 | /** 663 | * Performs traversal on a TreeWalker, visiting each subtree in document order. 664 | * When visiting a subtree not already visited (its root not in finishedSubtrees 665 | * ), first the root is emitted then the subtree is traversed, then the root is 666 | * emitted again and then the next subtree in document order is visited. 667 | * 668 | * Subtree's roots are emitted twice to signal the beginning and ending of 669 | * element nodes. This is useful for ensuring the ends of block boundaries are 670 | * found. 671 | * @param {TreeWalker} walker - the TreeWalker to be traversed 672 | * @param {Set} finishedSubtrees - set of subtree roots already visited 673 | * @return {Node} - next node in the traversal 674 | */ 675 | const forwardTraverse = (walker, finishedSubtrees) => { 676 | // If current node's subtree is not already finished 677 | // try to go first down the subtree. 678 | if (!finishedSubtrees.has(walker.currentNode)) { 679 | const firstChild = walker.firstChild(); 680 | if (firstChild !== null) { 681 | return firstChild; 682 | } 683 | } 684 | 685 | // If no subtree go to next sibling if any. 686 | const nextSibling = walker.nextSibling(); 687 | if (nextSibling !== null) { 688 | return nextSibling; 689 | } 690 | 691 | // If no sibling go back to parent and mark it as finished. 692 | const parent = walker.parentNode(); 693 | if (parent !== null) { 694 | finishedSubtrees.add(parent); 695 | } 696 | return parent; 697 | }; 698 | 699 | /** 700 | * Performs backwards traversal on a TreeWalker, visiting each subtree in 701 | * backwards document order. When visiting a subtree not already visited (its 702 | * root not in finishedSubtrees ), first the root is emitted then the subtree is 703 | * backward traversed, then the root is emitted again and then the previous 704 | * subtree in document order is visited. 705 | * 706 | * Subtree's roots are emitted twice to signal the beginning and ending of 707 | * element nodes. This is useful for ensuring block boundaries are found. 708 | * @param {TreeWalker} walker - the TreeWalker to be traversed 709 | * @param {Set} finishedSubtrees - set of subtree roots already visited 710 | * @return {Node} - next node in the backwards traversal 711 | */ 712 | const backwardTraverse = (walker, finishedSubtrees) => { 713 | // If current node's subtree is not already finished 714 | // try to go first down the subtree. 715 | if (!finishedSubtrees.has(walker.currentNode)) { 716 | const lastChild = walker.lastChild(); 717 | if (lastChild !== null) { 718 | return lastChild; 719 | } 720 | } 721 | 722 | // If no subtree go to previous sibling if any. 723 | const previousSibling = walker.previousSibling(); 724 | if (previousSibling !== null) { 725 | return previousSibling; 726 | } 727 | 728 | // If no sibling go back to parent and mark it as finished. 729 | const parent = walker.parentNode(); 730 | if (parent !== null) { 731 | finishedSubtrees.add(parent); 732 | } 733 | return parent; 734 | }; 735 | 736 | /** 737 | * Should only be used by other files in this directory. 738 | */ 739 | const internal = { 740 | BLOCK_ELEMENTS: BLOCK_ELEMENTS, 741 | BOUNDARY_CHARS: BOUNDARY_CHARS, 742 | NON_BOUNDARY_CHARS: NON_BOUNDARY_CHARS, 743 | acceptNodeIfVisibleInRange: acceptNodeIfVisibleInRange, 744 | normalizeString: normalizeString, 745 | makeNewSegmenter: makeNewSegmenter, 746 | forwardTraverse: forwardTraverse, 747 | backwardTraverse: backwardTraverse, 748 | makeTextNodeWalker: makeTextNodeWalker, 749 | isNodeVisible: isNodeVisible 750 | }; 751 | 752 | // Allow importing module from closure-compiler projects that haven't migrated 753 | // to ES6 modules. 754 | if (typeof goog !== 'undefined') { 755 | // clang-format off 756 | goog.declareModuleId('googleChromeLabs.textFragmentPolyfill.textFragmentUtils'); 757 | // clang-format on 758 | } 759 | 760 | /** 761 | * Copyright 2020 Google LLC 762 | * 763 | * Licensed under the Apache License, Version 2.0 (the "License"); 764 | * you may not use this file except in compliance with the License. 765 | * You may obtain a copy of the License at 766 | * 767 | * https://www.apache.org/licenses/LICENSE-2.0 768 | * 769 | * Unless required by applicable law or agreed to in writing, software 770 | * distributed under the License is distributed on an "AS IS" BASIS, 771 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 772 | * See the License for the specific language governing permissions and 773 | * limitations under the License. 774 | */ 775 | 776 | const MAX_EXACT_MATCH_LENGTH = 300; 777 | const MIN_LENGTH_WITHOUT_CONTEXT = 20; 778 | const ITERATIONS_BEFORE_ADDING_CONTEXT = 1; 779 | const WORDS_TO_ADD_FIRST_ITERATION = 3; 780 | const WORDS_TO_ADD_SUBSEQUENT_ITERATIONS = 1; 781 | const TRUNCATE_RANGE_CHECK_CHARS = 10000; 782 | const MAX_DEPTH = 500; 783 | 784 | // Desired max run time, in ms. Can be overwritten. 785 | let timeoutDurationMs = 500; 786 | let t0; // Start timestamp for fragment generation 787 | 788 | /** 789 | * Allows overriding the max runtime to specify a different interval. Fragment 790 | * generation will halt and throw an error after this amount of time. 791 | * @param {Number} newTimeoutDurationMs - the desired timeout length, in ms. 792 | */ 793 | const setTimeout = newTimeoutDurationMs => { 794 | timeoutDurationMs = newTimeoutDurationMs; 795 | }; 796 | 797 | /** 798 | * Enum indicating the success, or failure reason, of generateFragment. 799 | */ 800 | exports.setTimeout = setTimeout; 801 | const GenerateFragmentStatus = exports.GenerateFragmentStatus = { 802 | SUCCESS: 0, 803 | // A fragment was generated. 804 | INVALID_SELECTION: 1, 805 | // The selection provided could not be used. 806 | AMBIGUOUS: 2, 807 | // No unique fragment could be identified for this selection. 808 | TIMEOUT: 3, 809 | // Computation could not complete in time. 810 | EXECUTION_FAILED: 4 // An exception was raised during generation. 811 | }; 812 | 813 | /** 814 | * @typedef {Object} GenerateFragmentResult 815 | * @property {GenerateFragmentStatus} status 816 | * @property {TextFragment} [fragment] 817 | */ 818 | 819 | /** 820 | * Attempts to generate a fragment, suitable for formatting and including in a 821 | * URL, which will highlight the given selection upon opening. 822 | * @param {Selection} selection - a Selection object, the result of 823 | * window.getSelection 824 | * @param {Date} [startTime] - the time when generation began, for timeout 825 | * purposes. Defaults to current timestamp. 826 | * @return {GenerateFragmentResult} 827 | */ 828 | const generateFragment = (selection, startTime = Date.now()) => { 829 | return doGenerateFragment(selection, startTime); 830 | }; 831 | 832 | /** 833 | * Attampts to generate a fragment using a given range. @see {@link generateFragment} 834 | * 835 | * @param {Range} range 836 | * @param {Date} [startTime] - the time when generation began, for timeout 837 | * purposes. Defaults to current timestamp. 838 | * @return {GenerateFragmentResult} 839 | */ 840 | exports.generateFragment = generateFragment; 841 | const generateFragmentFromRange = (range, startTime = Date.now()) => { 842 | try { 843 | return doGenerateFragmentFromRange(range, startTime); 844 | } catch (err) { 845 | if (err.isTimeout) { 846 | return { 847 | status: GenerateFragmentStatus.TIMEOUT 848 | }; 849 | } else { 850 | return { 851 | status: GenerateFragmentStatus.EXECUTION_FAILED 852 | }; 853 | } 854 | } 855 | }; 856 | 857 | /** 858 | * Checks whether fragment generation can be attempted for a given range. This 859 | * checks a handful of simple conditions: the range must be nonempty, not inside 860 | * an , etc. A true return is not a guarantee that fragment generation 861 | * will succeed; instead, this is a way to quickly rule out generation in cases 862 | * where a failure is predictable. 863 | * @param {Range} range 864 | * @return {boolean} - true if fragment generation may proceed; false otherwise. 865 | */ 866 | exports.generateFragmentFromRange = generateFragmentFromRange; 867 | const isValidRangeForFragmentGeneration = range => { 868 | // Check that the range isn't just punctuation and whitespace. Only check the 869 | // first |TRUNCATE_RANGE_CHECK_CHARS| to put an upper bound on runtime; ranges 870 | // that start with (e.g.) thousands of periods should be rare. 871 | // This also implicitly ensures the selection isn't in an input or textarea 872 | // field, as document.selection contains an empty range in these cases. 873 | if (!range.toString().substring(0, TRUNCATE_RANGE_CHECK_CHARS).match(internal.NON_BOUNDARY_CHARS)) { 874 | return false; 875 | } 876 | 877 | // Check for iframe 878 | try { 879 | if (range.startContainer.ownerDocument.defaultView !== window.top) { 880 | return false; 881 | } 882 | } catch { 883 | // If accessing window.top throws an error, this is in a cross-origin 884 | // iframe. 885 | return false; 886 | } 887 | 888 | // Walk up the DOM to ensure that the range isn't inside an editable. Limit 889 | // the search depth to |MAX_DEPTH| to constrain runtime. 890 | let node = range.commonAncestorContainer; 891 | let numIterations = 0; 892 | while (node) { 893 | if (node.nodeType == Node.ELEMENT_NODE) { 894 | if (['TEXTAREA', 'INPUT'].includes(node.tagName.toUpperCase())) { 895 | return false; 896 | } 897 | const editable = node.attributes.getNamedItem('contenteditable'); 898 | if (editable && editable.value !== 'false') { 899 | return false; 900 | } 901 | 902 | // Cap the number of iterations at |MAX_PRECONDITION_DEPTH| to put an 903 | // upper bound on runtime. 904 | numIterations++; 905 | if (numIterations >= MAX_DEPTH) { 906 | return false; 907 | } 908 | } 909 | node = node.parentNode; 910 | } 911 | return true; 912 | }; 913 | 914 | /** 915 | * @param {Selection} selection 916 | * @param {Date} startTime 917 | * @return {GenerateFragmentResult} 918 | * @see {@link generateFragment} - this method wraps the error-throwing portions 919 | * of that method. 920 | * @throws {Error} - Will throw if computation takes longer than the accepted 921 | * timeout length. 922 | */ 923 | exports.isValidRangeForFragmentGeneration = isValidRangeForFragmentGeneration; 924 | const doGenerateFragment = (selection, startTime) => { 925 | let range; 926 | try { 927 | range = selection.getRangeAt(0); 928 | } catch { 929 | return { 930 | status: GenerateFragmentStatus.INVALID_SELECTION 931 | }; 932 | } 933 | return doGenerateFragmentFromRange(range, startTime); 934 | }; 935 | /** 936 | * @param {Range} range 937 | * @param {Date} startTime 938 | * @return {GenerateFragmentResult} 939 | * @see {@link doGenerateFragment} 940 | */ 941 | const doGenerateFragmentFromRange = (range, startTime) => { 942 | recordStartTime(startTime); 943 | expandRangeStartToWordBound(range); 944 | expandRangeEndToWordBound(range); 945 | // Keep a copy of the range before we try to shrink it to make it start and 946 | // end in text nodes. We need to use the range edges as starting points 947 | // for context term building, so it makes sense to start from the original 948 | // edges instead of the edges after shrinking. This way we don't have to 949 | // traverse all the non-text nodes that are between the edges after shrinking 950 | // and the original ones. 951 | const rangeBeforeShrinking = range.cloneRange(); 952 | moveRangeEdgesToTextNodes(range); 953 | if (range.collapsed) { 954 | return { 955 | status: GenerateFragmentStatus.INVALID_SELECTION 956 | }; 957 | } 958 | let factory; 959 | if (canUseExactMatch(range)) { 960 | const exactText = internal.normalizeString(range.toString()); 961 | const fragment = { 962 | textStart: exactText 963 | }; 964 | // If the exact text is long enough to be used on its own, try this and skip 965 | // the longer process below. 966 | if (exactText.length >= MIN_LENGTH_WITHOUT_CONTEXT && isUniquelyIdentifying(fragment)) { 967 | return { 968 | status: GenerateFragmentStatus.SUCCESS, 969 | fragment: fragment 970 | }; 971 | } 972 | factory = new FragmentFactory().setExactTextMatch(exactText); 973 | } else { 974 | // We have to use textStart and textEnd to identify a range. First, break 975 | // the range up based on block boundaries, as textStart/textEnd can't cross 976 | // these. 977 | const startSearchSpace = getSearchSpaceForStart(range); 978 | const endSearchSpace = getSearchSpaceForEnd(range); 979 | if (startSearchSpace && endSearchSpace) { 980 | // If the search spaces are truthy, then there's a block boundary between 981 | // them. 982 | factory = new FragmentFactory().setStartAndEndSearchSpace(startSearchSpace, endSearchSpace); 983 | } else { 984 | // If the search space was empty/undefined, it's because no block boundary 985 | // was found. That means textStart and textEnd *share* a search space, so 986 | // our approach must ensure the substrings chosen as candidates don't 987 | // overlap. 988 | factory = new FragmentFactory().setSharedSearchSpace(range.toString().trim()); 989 | } 990 | } 991 | const prefixRange = document.createRange(); 992 | prefixRange.selectNodeContents(document.body); 993 | const suffixRange = prefixRange.cloneRange(); 994 | prefixRange.setEnd(rangeBeforeShrinking.startContainer, rangeBeforeShrinking.startOffset); 995 | suffixRange.setStart(rangeBeforeShrinking.endContainer, rangeBeforeShrinking.endOffset); 996 | const prefixSearchSpace = getSearchSpaceForEnd(prefixRange); 997 | const suffixSearchSpace = getSearchSpaceForStart(suffixRange); 998 | if (prefixSearchSpace || suffixSearchSpace) { 999 | factory.setPrefixAndSuffixSearchSpace(prefixSearchSpace, suffixSearchSpace); 1000 | } 1001 | factory.useSegmenter(internal.makeNewSegmenter()); 1002 | let didEmbiggen = false; 1003 | do { 1004 | checkTimeout(); 1005 | didEmbiggen = factory.embiggen(); 1006 | const fragment = factory.tryToMakeUniqueFragment(); 1007 | if (fragment != null) { 1008 | return { 1009 | status: GenerateFragmentStatus.SUCCESS, 1010 | fragment: fragment 1011 | }; 1012 | } 1013 | } while (didEmbiggen); 1014 | return { 1015 | status: GenerateFragmentStatus.AMBIGUOUS 1016 | }; 1017 | }; 1018 | 1019 | /** 1020 | * @throws {Error} - if the timeout duration has been exceeded, an error will 1021 | * be thrown so that execution can be halted. 1022 | */ 1023 | const checkTimeout = () => { 1024 | // disable check when no timeout duration specified 1025 | if (timeoutDurationMs === null) { 1026 | return; 1027 | } 1028 | const delta = Date.now() - t0; 1029 | if (delta > timeoutDurationMs) { 1030 | const timeoutError = new Error(`Fragment generation timed out after ${delta} ms.`); 1031 | timeoutError.isTimeout = true; 1032 | throw timeoutError; 1033 | } 1034 | }; 1035 | 1036 | /** 1037 | * Call at the start of fragment generation to set the baseline for timeout 1038 | * checking. 1039 | * @param {Date} newStartTime - the timestamp when fragment generation began 1040 | */ 1041 | const recordStartTime = newStartTime => { 1042 | t0 = newStartTime; 1043 | }; 1044 | 1045 | /** 1046 | * Finds the search space for parameters when using range or suffix match. 1047 | * This is the text from the start of the range to the first block boundary, 1048 | * trimmed to remove any leading/trailing whitespace characters. 1049 | * @param {Range} range - the range which will be highlighted. 1050 | * @return {String|Undefined} - the text which may be used for constructing a 1051 | * textStart parameter identifying this range. Will return undefined if no 1052 | * block boundaries are found inside this range, or if all the candidate 1053 | * ranges were empty (or included only whitespace characters). 1054 | */ 1055 | const getSearchSpaceForStart = range => { 1056 | let node = getFirstNodeForBlockSearch(range); 1057 | const walker = makeWalkerForNode(node, range.endContainer); 1058 | if (!walker) { 1059 | return undefined; 1060 | } 1061 | const finishedSubtrees = new Set(); 1062 | // If the range starts after the last child of an element node 1063 | // don't visit its subtree because it's not included in the range. 1064 | if (range.startContainer.nodeType === Node.ELEMENT_NODE && range.startOffset === range.startContainer.childNodes.length) { 1065 | finishedSubtrees.add(range.startContainer); 1066 | } 1067 | const origin = node; 1068 | const textAccumulator = new BlockTextAccumulator(range, true); 1069 | // tempRange monitors whether we've exhausted our search space yet. 1070 | const tempRange = range.cloneRange(); 1071 | while (!tempRange.collapsed && node != null) { 1072 | checkTimeout(); 1073 | // Depending on whether |node| is an ancestor of the start of our 1074 | // search, we use either its leading or trailing edge as our start. 1075 | if (node.contains(origin)) { 1076 | tempRange.setStartAfter(node); 1077 | } else { 1078 | tempRange.setStartBefore(node); 1079 | } 1080 | // Add node to accumulator to keep track of text inside the current block 1081 | // boundaries 1082 | textAccumulator.appendNode(node); 1083 | 1084 | // If the accumulator found a non empty block boundary we've got our search 1085 | // space. 1086 | if (textAccumulator.textInBlock !== null) { 1087 | return textAccumulator.textInBlock; 1088 | } 1089 | node = internal.forwardTraverse(walker, finishedSubtrees); 1090 | } 1091 | return undefined; 1092 | }; 1093 | 1094 | /** 1095 | * Finds the search space for parameters when using range or prefix match. 1096 | * This is the text from the last block boundary to the end of the range, 1097 | * trimmed to remove any leading/trailing whitespace characters. 1098 | * @param {Range} range - the range which will be highlighted. 1099 | * @return {String|Undefined} - the text which may be used for constructing a 1100 | * textEnd parameter identifying this range. Will return undefined if no 1101 | * block boundaries are found inside this range, or if all the candidate 1102 | * ranges were empty (or included only whitespace characters). 1103 | */ 1104 | const getSearchSpaceForEnd = range => { 1105 | let node = getLastNodeForBlockSearch(range); 1106 | const walker = makeWalkerForNode(node, range.startContainer); 1107 | if (!walker) { 1108 | return undefined; 1109 | } 1110 | const finishedSubtrees = new Set(); 1111 | // If the range ends before the first child of an element node 1112 | // don't visit its subtree because it's not included in the range. 1113 | if (range.endContainer.nodeType === Node.ELEMENT_NODE && range.endOffset === 0) { 1114 | finishedSubtrees.add(range.endContainer); 1115 | } 1116 | const origin = node; 1117 | const textAccumulator = new BlockTextAccumulator(range, false); 1118 | 1119 | // tempRange monitors whether we've exhausted our search space yet. 1120 | const tempRange = range.cloneRange(); 1121 | while (!tempRange.collapsed && node != null) { 1122 | checkTimeout(); 1123 | // Depending on whether |node| is an ancestor of the start of our 1124 | // search, we use either its leading or trailing edge as our end. 1125 | if (node.contains(origin)) { 1126 | tempRange.setEnd(node, 0); 1127 | } else { 1128 | tempRange.setEndAfter(node); 1129 | } 1130 | 1131 | // Add node to accumulator to keep track of text inside the current block 1132 | // boundaries. 1133 | textAccumulator.appendNode(node); 1134 | 1135 | // If the accumulator found a non empty block boundary we've got our search 1136 | // space. 1137 | if (textAccumulator.textInBlock !== null) { 1138 | return textAccumulator.textInBlock; 1139 | } 1140 | node = internal.backwardTraverse(walker, finishedSubtrees); 1141 | } 1142 | return undefined; 1143 | }; 1144 | 1145 | /** 1146 | * Helper class for constructing range-based fragments for selections that cross 1147 | * block boundaries. 1148 | */ 1149 | const FragmentFactory = class { 1150 | /** 1151 | * Initializes the basic state of the factory. Users should then call exactly 1152 | * one of setStartAndEndSearchSpace, setSharedSearchSpace, or 1153 | * setExactTextMatch, and optionally setPrefixAndSuffixSearchSpace. 1154 | */ 1155 | constructor() { 1156 | this.Mode = { 1157 | ALL_PARTS: 1, 1158 | SHARED_START_AND_END: 2, 1159 | CONTEXT_ONLY: 3 1160 | }; 1161 | this.startOffset = null; 1162 | this.endOffset = null; 1163 | this.prefixOffset = null; 1164 | this.suffixOffset = null; 1165 | this.prefixSearchSpace = ''; 1166 | this.backwardsPrefixSearchSpace = ''; 1167 | this.suffixSearchSpace = ''; 1168 | this.numIterations = 0; 1169 | } 1170 | 1171 | /** 1172 | * Generates a fragment based on the current state, then tests it for 1173 | * uniqueness. 1174 | * @return {TextFragment|Undefined} - a text fragment if the current state is 1175 | * uniquely identifying, or undefined if the current state is ambiguous. 1176 | */ 1177 | tryToMakeUniqueFragment() { 1178 | let fragment; 1179 | if (this.mode === this.Mode.CONTEXT_ONLY) { 1180 | fragment = { 1181 | textStart: this.exactTextMatch 1182 | }; 1183 | } else { 1184 | fragment = { 1185 | textStart: this.getStartSearchSpace().substring(0, this.startOffset).trim(), 1186 | textEnd: this.getEndSearchSpace().substring(this.endOffset).trim() 1187 | }; 1188 | } 1189 | if (this.prefixOffset != null) { 1190 | const prefix = this.getPrefixSearchSpace().substring(this.prefixOffset).trim(); 1191 | if (prefix) { 1192 | fragment.prefix = prefix; 1193 | } 1194 | } 1195 | if (this.suffixOffset != null) { 1196 | const suffix = this.getSuffixSearchSpace().substring(0, this.suffixOffset).trim(); 1197 | if (suffix) { 1198 | fragment.suffix = suffix; 1199 | } 1200 | } 1201 | return isUniquelyIdentifying(fragment) ? fragment : undefined; 1202 | } 1203 | 1204 | /** 1205 | * Shifts the current state such that the candidates for textStart and textEnd 1206 | * represent more of the possible search spaces. 1207 | * @return {boolean} - true if the desired expansion occurred; false if the 1208 | * entire search space has been consumed and no further attempts can be 1209 | * made. 1210 | */ 1211 | embiggen() { 1212 | let canExpandRange = true; 1213 | if (this.mode === this.Mode.SHARED_START_AND_END) { 1214 | if (this.startOffset >= this.endOffset) { 1215 | // If the search space is shared between textStart and textEnd, then 1216 | // stop expanding when textStart overlaps textEnd. 1217 | canExpandRange = false; 1218 | } 1219 | } else if (this.mode === this.Mode.ALL_PARTS) { 1220 | // Stop expanding if both start and end have already consumed their full 1221 | // search spaces. 1222 | if (this.startOffset === this.getStartSearchSpace().length && this.backwardsEndOffset() === this.getEndSearchSpace().length) { 1223 | canExpandRange = false; 1224 | } 1225 | } else if (this.mode === this.Mode.CONTEXT_ONLY) { 1226 | canExpandRange = false; 1227 | } 1228 | if (canExpandRange) { 1229 | const desiredIterations = this.getNumberOfRangeWordsToAdd(); 1230 | if (this.startOffset < this.getStartSearchSpace().length) { 1231 | let i = 0; 1232 | if (this.getStartSegments() != null) { 1233 | while (i < desiredIterations && this.startOffset < this.getStartSearchSpace().length) { 1234 | this.startOffset = this.getNextOffsetForwards(this.getStartSegments(), this.startOffset, this.getStartSearchSpace()); 1235 | i++; 1236 | } 1237 | } else { 1238 | // We don't have a segmenter, so find the next boundary character 1239 | // instead. Shift to the next boundary char, and repeat until we've 1240 | // added a word char. 1241 | let oldStartOffset = this.startOffset; 1242 | do { 1243 | checkTimeout(); 1244 | const newStartOffset = this.getStartSearchSpace().substring(this.startOffset + 1).search(internal.BOUNDARY_CHARS); 1245 | if (newStartOffset === -1) { 1246 | this.startOffset = this.getStartSearchSpace().length; 1247 | } else { 1248 | this.startOffset = this.startOffset + 1 + newStartOffset; 1249 | } 1250 | // Only count as an iteration if a word character was added. 1251 | if (this.getStartSearchSpace().substring(oldStartOffset, this.startOffset).search(internal.NON_BOUNDARY_CHARS) !== -1) { 1252 | oldStartOffset = this.startOffset; 1253 | i++; 1254 | } 1255 | } while (this.startOffset < this.getStartSearchSpace().length && i < desiredIterations); 1256 | } 1257 | 1258 | // Ensure we don't have overlapping start and end offsets. 1259 | if (this.mode === this.Mode.SHARED_START_AND_END) { 1260 | this.startOffset = Math.min(this.startOffset, this.endOffset); 1261 | } 1262 | } 1263 | if (this.backwardsEndOffset() < this.getEndSearchSpace().length) { 1264 | let i = 0; 1265 | if (this.getEndSegments() != null) { 1266 | while (i < desiredIterations && this.endOffset > 0) { 1267 | this.endOffset = this.getNextOffsetBackwards(this.getEndSegments(), this.endOffset); 1268 | i++; 1269 | } 1270 | } else { 1271 | // No segmenter, so shift to the next boundary char, and repeat until 1272 | // we've added a word char. 1273 | let oldBackwardsEndOffset = this.backwardsEndOffset(); 1274 | do { 1275 | checkTimeout(); 1276 | const newBackwardsOffset = this.getBackwardsEndSearchSpace().substring(this.backwardsEndOffset() + 1).search(internal.BOUNDARY_CHARS); 1277 | if (newBackwardsOffset === -1) { 1278 | this.setBackwardsEndOffset(this.getEndSearchSpace().length); 1279 | } else { 1280 | this.setBackwardsEndOffset(this.backwardsEndOffset() + 1 + newBackwardsOffset); 1281 | } 1282 | // Only count as an iteration if a word character was added. 1283 | if (this.getBackwardsEndSearchSpace().substring(oldBackwardsEndOffset, this.backwardsEndOffset()).search(internal.NON_BOUNDARY_CHARS) !== -1) { 1284 | oldBackwardsEndOffset = this.backwardsEndOffset(); 1285 | i++; 1286 | } 1287 | } while (this.backwardsEndOffset() < this.getEndSearchSpace().length && i < desiredIterations); 1288 | } 1289 | // Ensure we don't have overlapping start and end offsets. 1290 | if (this.mode === this.Mode.SHARED_START_AND_END) { 1291 | this.endOffset = Math.max(this.startOffset, this.endOffset); 1292 | } 1293 | } 1294 | } 1295 | let canExpandContext = false; 1296 | if (!canExpandRange || this.startOffset + this.backwardsEndOffset() < MIN_LENGTH_WITHOUT_CONTEXT || this.numIterations >= ITERATIONS_BEFORE_ADDING_CONTEXT) { 1297 | // Check if there's any unused search space left. 1298 | if (this.backwardsPrefixOffset() != null && this.backwardsPrefixOffset() !== this.getPrefixSearchSpace().length || this.suffixOffset != null && this.suffixOffset !== this.getSuffixSearchSpace().length) { 1299 | canExpandContext = true; 1300 | } 1301 | } 1302 | if (canExpandContext) { 1303 | const desiredIterations = this.getNumberOfContextWordsToAdd(); 1304 | if (this.backwardsPrefixOffset() < this.getPrefixSearchSpace().length) { 1305 | let i = 0; 1306 | if (this.getPrefixSegments() != null) { 1307 | while (i < desiredIterations && this.prefixOffset > 0) { 1308 | this.prefixOffset = this.getNextOffsetBackwards(this.getPrefixSegments(), this.prefixOffset); 1309 | i++; 1310 | } 1311 | } else { 1312 | // Shift to the next boundary char, and repeat until we've added a 1313 | // word char. 1314 | let oldBackwardsPrefixOffset = this.backwardsPrefixOffset(); 1315 | do { 1316 | checkTimeout(); 1317 | const newBackwardsPrefixOffset = this.getBackwardsPrefixSearchSpace().substring(this.backwardsPrefixOffset() + 1).search(internal.BOUNDARY_CHARS); 1318 | if (newBackwardsPrefixOffset === -1) { 1319 | this.setBackwardsPrefixOffset(this.getBackwardsPrefixSearchSpace().length); 1320 | } else { 1321 | this.setBackwardsPrefixOffset(this.backwardsPrefixOffset() + 1 + newBackwardsPrefixOffset); 1322 | } 1323 | // Only count as an iteration if a word character was added. 1324 | if (this.getBackwardsPrefixSearchSpace().substring(oldBackwardsPrefixOffset, this.backwardsPrefixOffset()).search(internal.NON_BOUNDARY_CHARS) !== -1) { 1325 | oldBackwardsPrefixOffset = this.backwardsPrefixOffset(); 1326 | i++; 1327 | } 1328 | } while (this.backwardsPrefixOffset() < this.getPrefixSearchSpace().length && i < desiredIterations); 1329 | } 1330 | } 1331 | if (this.suffixOffset < this.getSuffixSearchSpace().length) { 1332 | let i = 0; 1333 | if (this.getSuffixSegments() != null) { 1334 | while (i < desiredIterations && this.suffixOffset < this.getSuffixSearchSpace().length) { 1335 | this.suffixOffset = this.getNextOffsetForwards(this.getSuffixSegments(), this.suffixOffset, this.getSuffixSearchSpace()); 1336 | i++; 1337 | } 1338 | } else { 1339 | let oldSuffixOffset = this.suffixOffset; 1340 | do { 1341 | checkTimeout(); 1342 | const newSuffixOffset = this.getSuffixSearchSpace().substring(this.suffixOffset + 1).search(internal.BOUNDARY_CHARS); 1343 | if (newSuffixOffset === -1) { 1344 | this.suffixOffset = this.getSuffixSearchSpace().length; 1345 | } else { 1346 | this.suffixOffset = this.suffixOffset + 1 + newSuffixOffset; 1347 | } 1348 | // Only count as an iteration if a word character was added. 1349 | if (this.getSuffixSearchSpace().substring(oldSuffixOffset, this.suffixOffset).search(internal.NON_BOUNDARY_CHARS) !== -1) { 1350 | oldSuffixOffset = this.suffixOffset; 1351 | i++; 1352 | } 1353 | } while (this.suffixOffset < this.getSuffixSearchSpace().length && i < desiredIterations); 1354 | } 1355 | } 1356 | } 1357 | this.numIterations++; 1358 | 1359 | // TODO: check if this exceeds the total length limit 1360 | return canExpandRange || canExpandContext; 1361 | } 1362 | 1363 | /** 1364 | * Sets up the factory for a range-based match with a highlight that crosses 1365 | * block boundaries. 1366 | * 1367 | * Exactly one of this, setSharedSearchSpace, or setExactTextMatch should be 1368 | * called so the factory can identify the fragment. 1369 | * 1370 | * @param {String} startSearchSpace - the maximum possible string which can be 1371 | * used to identify the start of the fragment 1372 | * @param {String} endSearchSpace - the maximum possible string which can be 1373 | * used to identify the end of the fragment 1374 | * @return {FragmentFactory} - returns |this| to allow call chaining and 1375 | * assignment 1376 | */ 1377 | setStartAndEndSearchSpace(startSearchSpace, endSearchSpace) { 1378 | this.startSearchSpace = startSearchSpace; 1379 | this.endSearchSpace = endSearchSpace; 1380 | this.backwardsEndSearchSpace = reverseString(endSearchSpace); 1381 | this.startOffset = 0; 1382 | this.endOffset = endSearchSpace.length; 1383 | this.mode = this.Mode.ALL_PARTS; 1384 | return this; 1385 | } 1386 | 1387 | /** 1388 | * Sets up the factory for a range-based match with a highlight that doesn't 1389 | * cross block boundaries. 1390 | * 1391 | * Exactly one of this, setStartAndEndSearchSpace, or setExactTextMatch should 1392 | * be called so the factory can identify the fragment. 1393 | * 1394 | * @param {String} sharedSearchSpace - the full text of the highlight 1395 | * @return {FragmentFactory} - returns |this| to allow call chaining and 1396 | * assignment 1397 | */ 1398 | setSharedSearchSpace(sharedSearchSpace) { 1399 | this.sharedSearchSpace = sharedSearchSpace; 1400 | this.backwardsSharedSearchSpace = reverseString(sharedSearchSpace); 1401 | this.startOffset = 0; 1402 | this.endOffset = sharedSearchSpace.length; 1403 | this.mode = this.Mode.SHARED_START_AND_END; 1404 | return this; 1405 | } 1406 | 1407 | /** 1408 | * Sets up the factory for an exact text match. 1409 | * 1410 | * Exactly one of this, setStartAndEndSearchSpace, or setSharedSearchSpace 1411 | * should be called so the factory can identify the fragment. 1412 | * 1413 | * @param {String} exactTextMatch - the full text of the highlight 1414 | * @return {FragmentFactory} - returns |this| to allow call chaining and 1415 | * assignment 1416 | */ 1417 | setExactTextMatch(exactTextMatch) { 1418 | this.exactTextMatch = exactTextMatch; 1419 | this.mode = this.Mode.CONTEXT_ONLY; 1420 | return this; 1421 | } 1422 | 1423 | /** 1424 | * Sets up the factory for context-based matches. 1425 | * 1426 | * @param {String} prefixSearchSpace - the string to be used as the search 1427 | * space for prefix 1428 | * @param {String} suffixSearchSpace - the string to be used as the search 1429 | * space for suffix 1430 | * @return {FragmentFactory} - returns |this| to allow call chaining and 1431 | * assignment 1432 | */ 1433 | setPrefixAndSuffixSearchSpace(prefixSearchSpace, suffixSearchSpace) { 1434 | if (prefixSearchSpace) { 1435 | this.prefixSearchSpace = prefixSearchSpace; 1436 | this.backwardsPrefixSearchSpace = reverseString(prefixSearchSpace); 1437 | this.prefixOffset = prefixSearchSpace.length; 1438 | } 1439 | if (suffixSearchSpace) { 1440 | this.suffixSearchSpace = suffixSearchSpace; 1441 | this.suffixOffset = 0; 1442 | } 1443 | return this; 1444 | } 1445 | 1446 | /** 1447 | * Sets up the factory to use an instance of Intl.Segmenter when identifying 1448 | * the start/end of words. |segmenter| is not actually retained; instead it is 1449 | * used to create segment objects which are cached. 1450 | * 1451 | * This must be called AFTER any calls to setStartAndEndSearchSpace, 1452 | * setSharedSearchSpace, and/or setPrefixAndSuffixSearchSpace, as these search 1453 | * spaces will be segmented immediately. 1454 | * 1455 | * @param {Intl.Segmenter | Undefined} segmenter 1456 | * @return {FragmentFactory} - returns |this| to allow call chaining and 1457 | * assignment 1458 | */ 1459 | useSegmenter(segmenter) { 1460 | if (segmenter == null) { 1461 | return this; 1462 | } 1463 | if (this.mode === this.Mode.ALL_PARTS) { 1464 | this.startSegments = segmenter.segment(this.startSearchSpace); 1465 | this.endSegments = segmenter.segment(this.endSearchSpace); 1466 | } else if (this.mode === this.Mode.SHARED_START_AND_END) { 1467 | this.sharedSegments = segmenter.segment(this.sharedSearchSpace); 1468 | } 1469 | if (this.prefixSearchSpace) { 1470 | this.prefixSegments = segmenter.segment(this.prefixSearchSpace); 1471 | } 1472 | if (this.suffixSearchSpace) { 1473 | this.suffixSegments = segmenter.segment(this.suffixSearchSpace); 1474 | } 1475 | return this; 1476 | } 1477 | 1478 | /** 1479 | * @return {number} - how many words should be added to the prefix and suffix 1480 | * when embiggening. This changes depending on the current state of the 1481 | * prefix/suffix, so it should be invoked once per embiggen, before either 1482 | * is modified. 1483 | */ 1484 | getNumberOfContextWordsToAdd() { 1485 | return this.backwardsPrefixOffset() === 0 && this.suffixOffset === 0 ? WORDS_TO_ADD_FIRST_ITERATION : WORDS_TO_ADD_SUBSEQUENT_ITERATIONS; 1486 | } 1487 | 1488 | /** 1489 | * @return {number} - how many words should be added to textStart and textEnd 1490 | * when embiggening. This changes depending on the current state of 1491 | * textStart/textEnd, so it should be invoked once per embiggen, before 1492 | * either is modified. 1493 | */ 1494 | getNumberOfRangeWordsToAdd() { 1495 | return this.startOffset === 0 && this.backwardsEndOffset() === 0 ? WORDS_TO_ADD_FIRST_ITERATION : WORDS_TO_ADD_SUBSEQUENT_ITERATIONS; 1496 | } 1497 | 1498 | /** 1499 | * Helper method for embiggening using Intl.Segmenter. Finds the next offset 1500 | * to be tried in the forwards direction (i.e., a prefix of the search space). 1501 | * @param {Segments} segments - the output of segmenting the desired search 1502 | * space using Intl.Segmenter 1503 | * @param {number} offset - the current offset 1504 | * @param {string} searchSpace - the search space that was segmented 1505 | * @return {number} - the next offset which should be tried. 1506 | */ 1507 | getNextOffsetForwards(segments, offset, searchSpace) { 1508 | // Find the nearest wordlike segment and move to the end of it. 1509 | let currentSegment = segments.containing(offset); 1510 | while (currentSegment != null) { 1511 | checkTimeout(); 1512 | const currentSegmentEnd = currentSegment.index + currentSegment.segment.length; 1513 | if (currentSegment.isWordLike) { 1514 | return currentSegmentEnd; 1515 | } 1516 | currentSegment = segments.containing(currentSegmentEnd); 1517 | } 1518 | // If we didn't find a wordlike segment by the end of the string, set the 1519 | // offset to the full search space. 1520 | return searchSpace.length; 1521 | } 1522 | 1523 | /** 1524 | * Helper method for embiggening using Intl.Segmenter. Finds the next offset 1525 | * to be tried in the backwards direction (i.e., a suffix of the search 1526 | * space). 1527 | * @param {Segments} segments - the output of segmenting the desired search 1528 | * space using Intl.Segmenter 1529 | * @param {number} offset - the current offset 1530 | * @return {number} - the next offset which should be tried. 1531 | */ 1532 | getNextOffsetBackwards(segments, offset) { 1533 | // Find the nearest wordlike segment and move to the start of it. 1534 | let currentSegment = segments.containing(offset); 1535 | 1536 | // Handle two edge cases: 1537 | // 1. |offset| is at the end of the search space, so |currentSegment| 1538 | // is undefined 1539 | // 2. We're already at the start of a segment, so moving to the start of 1540 | // |currentSegment| would be a no-op. 1541 | // In both cases, the solution is to grab the segment immediately 1542 | // prior to this offset. 1543 | if (!currentSegment || offset == currentSegment.index) { 1544 | // If offset is 0, this will return null, which is handled below. 1545 | currentSegment = segments.containing(offset - 1); 1546 | } 1547 | while (currentSegment != null) { 1548 | checkTimeout(); 1549 | if (currentSegment.isWordLike) { 1550 | return currentSegment.index; 1551 | } 1552 | currentSegment = segments.containing(currentSegment.index - 1); 1553 | } 1554 | // If we didn't find a wordlike segment by the start of the string, 1555 | // set the offset to the full search space. 1556 | return 0; 1557 | } 1558 | 1559 | /** 1560 | * @return {String} - the string to be used as the search space for textStart 1561 | */ 1562 | getStartSearchSpace() { 1563 | return this.mode === this.Mode.SHARED_START_AND_END ? this.sharedSearchSpace : this.startSearchSpace; 1564 | } 1565 | 1566 | /** 1567 | * @return {Segments | Undefined} - the result of segmenting the start search 1568 | * space using Intl.Segmenter, or undefined if a segmenter was not 1569 | * provided. 1570 | */ 1571 | getStartSegments() { 1572 | return this.mode === this.Mode.SHARED_START_AND_END ? this.sharedSegments : this.startSegments; 1573 | } 1574 | 1575 | /** 1576 | * @return {String} - the string to be used as the search space for textEnd 1577 | */ 1578 | getEndSearchSpace() { 1579 | return this.mode === this.Mode.SHARED_START_AND_END ? this.sharedSearchSpace : this.endSearchSpace; 1580 | } 1581 | 1582 | /** 1583 | * @return {Segments | Undefined} - the result of segmenting the end search 1584 | * space using Intl.Segmenter, or undefined if a segmenter was not 1585 | * provided. 1586 | */ 1587 | getEndSegments() { 1588 | return this.mode === this.Mode.SHARED_START_AND_END ? this.sharedSegments : this.endSegments; 1589 | } 1590 | 1591 | /** 1592 | * @return {String} - the string to be used as the search space for textEnd, 1593 | * backwards. 1594 | */ 1595 | getBackwardsEndSearchSpace() { 1596 | return this.mode === this.Mode.SHARED_START_AND_END ? this.backwardsSharedSearchSpace : this.backwardsEndSearchSpace; 1597 | } 1598 | 1599 | /** 1600 | * @return {String} - the string to be used as the search space for prefix 1601 | */ 1602 | getPrefixSearchSpace() { 1603 | return this.prefixSearchSpace; 1604 | } 1605 | 1606 | /** 1607 | * @return {Segments | Undefined} - the result of segmenting the prefix 1608 | * search space using Intl.Segmenter, or undefined if a segmenter was not 1609 | * provided. 1610 | */ 1611 | getPrefixSegments() { 1612 | return this.prefixSegments; 1613 | } 1614 | 1615 | /** 1616 | * @return {String} - the string to be used as the search space for prefix, 1617 | * backwards. 1618 | */ 1619 | getBackwardsPrefixSearchSpace() { 1620 | return this.backwardsPrefixSearchSpace; 1621 | } 1622 | 1623 | /** 1624 | * @return {String} - the string to be used as the search space for suffix 1625 | */ 1626 | getSuffixSearchSpace() { 1627 | return this.suffixSearchSpace; 1628 | } 1629 | 1630 | /** 1631 | * @return {Segments | Undefined} - the result of segmenting the suffix 1632 | * search space using Intl.Segmenter, or undefined if a segmenter was not 1633 | * provided. 1634 | */ 1635 | getSuffixSegments() { 1636 | return this.suffixSegments; 1637 | } 1638 | 1639 | /** 1640 | * Helper method for doing arithmetic in the backwards search space. 1641 | * @return {Number} - the current end offset, as a start offset in the 1642 | * backwards search space 1643 | */ 1644 | backwardsEndOffset() { 1645 | return this.getEndSearchSpace().length - this.endOffset; 1646 | } 1647 | 1648 | /** 1649 | * Helper method for doing arithmetic in the backwards search space. 1650 | * @param {Number} backwardsEndOffset - the desired new value of the start 1651 | * offset in the backwards search space 1652 | */ 1653 | setBackwardsEndOffset(backwardsEndOffset) { 1654 | this.endOffset = this.getEndSearchSpace().length - backwardsEndOffset; 1655 | } 1656 | 1657 | /** 1658 | * Helper method for doing arithmetic in the backwards search space. 1659 | * @return {Number} - the current prefix offset, as a start offset in the 1660 | * backwards search space 1661 | */ 1662 | backwardsPrefixOffset() { 1663 | if (this.prefixOffset == null) return null; 1664 | return this.getPrefixSearchSpace().length - this.prefixOffset; 1665 | } 1666 | 1667 | /** 1668 | * Helper method for doing arithmetic in the backwards search space. 1669 | * @param {Number} backwardsPrefixOffset - the desired new value of the prefix 1670 | * offset in the backwards search space 1671 | */ 1672 | setBackwardsPrefixOffset(backwardsPrefixOffset) { 1673 | if (this.prefixOffset == null) return; 1674 | this.prefixOffset = this.getPrefixSearchSpace().length - backwardsPrefixOffset; 1675 | } 1676 | }; 1677 | 1678 | /** 1679 | * Helper class to calculate visible text from the start or end of a range 1680 | * until a block boundary is reached or the range is exhausted. 1681 | */ 1682 | const BlockTextAccumulator = class { 1683 | /** 1684 | * @param {Range} searchRange - the range for which the text in the last or 1685 | * first non empty block boundary will be calculated 1686 | * @param {boolean} isForwardTraversal - true if nodes in 1687 | * searchRange will be forward traversed 1688 | */ 1689 | constructor(searchRange, isForwardTraversal) { 1690 | this.searchRange = searchRange; 1691 | this.isForwardTraversal = isForwardTraversal; 1692 | this.textFound = false; 1693 | this.textNodes = []; 1694 | this.textInBlock = null; 1695 | } 1696 | /** 1697 | * Adds the next node in the search space range traversal to the accumulator. 1698 | * The accumulator then will keep track of the text nodes in the range until a 1699 | * block boundary is found. Once a block boundary is found and the content of 1700 | * the text nodes in the boundary is non empty, the property textInBlock will 1701 | * be set with the content of the text nodes, trimmed of leading and trailing 1702 | * whitespaces. 1703 | * @param {Node} node - next node in the traversal of the searchRange 1704 | */ 1705 | appendNode(node) { 1706 | // If we already calculated the text in the block boundary just ignore any 1707 | // calls to append nodes. 1708 | if (this.textInBlock !== null) { 1709 | return; 1710 | } 1711 | // We found a block boundary, check if there's text inside and set it to 1712 | // textInBlock or keep going to the next block boundary. 1713 | if (isBlock(node)) { 1714 | if (this.textFound) { 1715 | // When traversing backwards the nodes are pushed in reverse order. 1716 | // Reversing them to get them in the right order. 1717 | if (!this.isForwardTraversal) { 1718 | this.textNodes.reverse(); 1719 | } 1720 | // Concatenate all the text nodes in the block boundary and trim any 1721 | // trailing and leading whitespaces. 1722 | this.textInBlock = this.textNodes.map(textNode => textNode.textContent).join('').trim(); 1723 | } else { 1724 | // Discard the text nodes visited so far since they are empty and we'll 1725 | // continue searching in the next block boundary. 1726 | this.textNodes = []; 1727 | } 1728 | return; 1729 | } 1730 | 1731 | // Ignore non text nodes. 1732 | if (!isText(node)) return; 1733 | 1734 | // Get the part of node inside the search range. This is to avoid 1735 | // accumulating text that's not inside the range. 1736 | const nodeToInsert = this.getNodeIntersectionWithRange(node); 1737 | 1738 | // Keep track of any text found in the block boundary. 1739 | this.textFound = this.textFound || nodeToInsert.textContent.trim() !== ''; 1740 | this.textNodes.push(nodeToInsert); 1741 | } 1742 | 1743 | /** 1744 | * Calculates the intersection of a node with searchRange and returns a Text 1745 | * Node with the intersection 1746 | * @param {Node} node - the node to intercept with searchRange 1747 | * @return {Node} - node if node is fully within searchRange or a Text Node 1748 | * with the substring of the content of node inside the search range 1749 | */ 1750 | getNodeIntersectionWithRange(node) { 1751 | let startOffset = null; 1752 | let endOffset = null; 1753 | if (node === this.searchRange.startContainer && this.searchRange.startOffset !== 0) { 1754 | startOffset = this.searchRange.startOffset; 1755 | } 1756 | if (node === this.searchRange.endContainer && this.searchRange.endOffset !== node.textContent.length) { 1757 | endOffset = this.searchRange.endOffset; 1758 | } 1759 | if (startOffset !== null || endOffset !== null) { 1760 | return { 1761 | textContent: node.textContent.substring(startOffset ?? 0, endOffset ?? node.textContent.length) 1762 | }; 1763 | } 1764 | return node; 1765 | } 1766 | }; 1767 | 1768 | /** 1769 | * @param {TextFragment} fragment - the candidate fragment 1770 | * @return {boolean} - true iff the candidate fragment identifies exactly one 1771 | * portion of the document. 1772 | */ 1773 | const isUniquelyIdentifying = fragment => { 1774 | return processTextFragmentDirective(fragment).length === 1; 1775 | }; 1776 | 1777 | /** 1778 | * Reverses a string. Compound unicode characters are preserved. 1779 | * @param {String} string - the string to reverse 1780 | * @return {String} - sdrawkcab |gnirts| 1781 | */ 1782 | const reverseString = string => { 1783 | // Spread operator (...) splits full characters, rather than code points, to 1784 | // avoid breaking compound unicode characters upon reverse. 1785 | return [...(string || '')].reverse().join(''); 1786 | }; 1787 | 1788 | /** 1789 | * Determines whether the conditions for an exact match are met. 1790 | * @param {Range} range - the range for which a fragment is being generated. 1791 | * @return {boolean} - true if exact matching (i.e., only 1792 | * textStart) can be used; false if range matching (i.e., both textStart and 1793 | * textEnd) must be used. 1794 | */ 1795 | const canUseExactMatch = range => { 1796 | if (range.toString().length > MAX_EXACT_MATCH_LENGTH) return false; 1797 | return !containsBlockBoundary(range); 1798 | }; 1799 | 1800 | /** 1801 | * Finds the node at which a forward traversal through |range| should begin, 1802 | * based on the range's start container and offset values. 1803 | * @param {Range} range - the range which will be traversed 1804 | * @return {Node} - the node where traversal should begin 1805 | */ 1806 | const getFirstNodeForBlockSearch = range => { 1807 | // Get a handle on the first node inside the range. For text nodes, this 1808 | // is the start container; for element nodes, we use the offset to find 1809 | // where it actually starts. 1810 | let node = range.startContainer; 1811 | if (node.nodeType == Node.ELEMENT_NODE && range.startOffset < node.childNodes.length) { 1812 | node = node.childNodes[range.startOffset]; 1813 | } 1814 | return node; 1815 | }; 1816 | 1817 | /** 1818 | * Finds the node at which a backward traversal through |range| should begin, 1819 | * based on the range's end container and offset values. 1820 | * @param {Range} range - the range which will be traversed 1821 | * @return {Node} - the node where traversal should begin 1822 | */ 1823 | const getLastNodeForBlockSearch = range => { 1824 | // Get a handle on the last node inside the range. For text nodes, this 1825 | // is the end container; for element nodes, we use the offset to find 1826 | // where it actually ends. If the offset is 0, the node itself is returned. 1827 | let node = range.endContainer; 1828 | if (node.nodeType == Node.ELEMENT_NODE && range.endOffset > 0) { 1829 | node = node.childNodes[range.endOffset - 1]; 1830 | } 1831 | return node; 1832 | }; 1833 | 1834 | /** 1835 | * Finds the first visible text node within a given range. 1836 | * @param {Range} range - range in which to find the first visible text node 1837 | * @return {Node} - first visible text node within |range| or null if there are 1838 | * no visible text nodes within |range| 1839 | */ 1840 | const getFirstTextNode = range => { 1841 | // Check if first node in the range is a visible text node. 1842 | const firstNode = getFirstNodeForBlockSearch(range); 1843 | if (isText(firstNode) && internal.isNodeVisible(firstNode)) { 1844 | return firstNode; 1845 | } 1846 | 1847 | // First node is not visible text, use a tree walker to find the first visible 1848 | // text node. 1849 | const walker = internal.makeTextNodeWalker(range); 1850 | walker.currentNode = firstNode; 1851 | return walker.nextNode(); 1852 | }; 1853 | 1854 | /** 1855 | * Finds the last visible text node within a given range. 1856 | * @param {Range} range - range in which to find the last visible text node 1857 | * @return {Node} - last visible text node within |range| or null if there are 1858 | * no visible text nodes within |range| 1859 | */ 1860 | const getLastTextNode = range => { 1861 | // Check if last node in the range is a visible text node. 1862 | const lastNode = getLastNodeForBlockSearch(range); 1863 | if (isText(lastNode) && internal.isNodeVisible(lastNode)) { 1864 | return lastNode; 1865 | } 1866 | 1867 | // Last node is not visible text, traverse the range backwards to find the 1868 | // last visible text node. 1869 | const walker = internal.makeTextNodeWalker(range); 1870 | walker.currentNode = lastNode; 1871 | return internal.backwardTraverse(walker, new Set()); 1872 | }; 1873 | 1874 | /** 1875 | * Determines whether or not a range crosses a block boundary. 1876 | * @param {Range} range - the range to investigate 1877 | * @return {boolean} - true if a block boundary was found, 1878 | * false if no such boundary was found. 1879 | */ 1880 | const containsBlockBoundary = range => { 1881 | const tempRange = range.cloneRange(); 1882 | let node = getFirstNodeForBlockSearch(tempRange); 1883 | const walker = makeWalkerForNode(node); 1884 | if (!walker) { 1885 | return false; 1886 | } 1887 | const finishedSubtrees = new Set(); 1888 | while (!tempRange.collapsed && node != null) { 1889 | if (isBlock(node)) return true; 1890 | if (node != null) tempRange.setStartAfter(node); 1891 | node = internal.forwardTraverse(walker, finishedSubtrees); 1892 | checkTimeout(); 1893 | } 1894 | return false; 1895 | }; 1896 | 1897 | /** 1898 | * Attempts to find a word start within the given text node, starting at 1899 | * |offset| and working backwards. 1900 | * 1901 | * @param {Node} node - a node to be searched 1902 | * @param {Number|Undefined} startOffset - the character offset within |node| 1903 | * where the selected text begins. If undefined, the entire node will be 1904 | * searched. 1905 | * @return {Number} the number indicating the offset to which a range should 1906 | * be set to ensure it starts on a word bound. Returns -1 if the node is not 1907 | * a text node, or if no word boundary character could be found. 1908 | */ 1909 | const findWordStartBoundInTextNode = (node, startOffset) => { 1910 | if (node.nodeType !== Node.TEXT_NODE) return -1; 1911 | const offset = startOffset != null ? startOffset : node.data.length; 1912 | 1913 | // If the first character in the range is a boundary character, we don't 1914 | // need to do anything. 1915 | if (offset < node.data.length && internal.BOUNDARY_CHARS.test(node.data[offset])) return offset; 1916 | const precedingText = node.data.substring(0, offset); 1917 | const boundaryIndex = reverseString(precedingText).search(internal.BOUNDARY_CHARS); 1918 | if (boundaryIndex !== -1) { 1919 | // Because we did a backwards search, the found index counts backwards 1920 | // from offset, so we subtract to find the start of the word. 1921 | return offset - boundaryIndex; 1922 | } 1923 | return -1; 1924 | }; 1925 | 1926 | /** 1927 | * Attempts to find a word end within the given text node, starting at |offset|. 1928 | * 1929 | * @param {Node} node - a node to be searched 1930 | * @param {Number|Undefined} endOffset - the character offset within |node| 1931 | * where the selected text end. If undefined, the entire node will be 1932 | * searched. 1933 | * @return {Number} the number indicating the offset to which a range should 1934 | * be set to ensure it ends on a word bound. Returns -1 if the node is not 1935 | * a text node, or if no word boundary character could be found. 1936 | */ 1937 | const findWordEndBoundInTextNode = (node, endOffset) => { 1938 | if (node.nodeType !== Node.TEXT_NODE) return -1; 1939 | const offset = endOffset != null ? endOffset : 0; 1940 | 1941 | // If the last character in the range is a boundary character, we don't 1942 | // need to do anything. 1943 | if (offset < node.data.length && offset > 0 && internal.BOUNDARY_CHARS.test(node.data[offset - 1])) { 1944 | return offset; 1945 | } 1946 | const followingText = node.data.substring(offset); 1947 | const boundaryIndex = followingText.search(internal.BOUNDARY_CHARS); 1948 | if (boundaryIndex !== -1) { 1949 | return offset + boundaryIndex; 1950 | } 1951 | return -1; 1952 | }; 1953 | 1954 | /** 1955 | * Helper method to create a TreeWalker useful for finding a block boundary near 1956 | * a given node. 1957 | * @param {Node} node - the node where the search should start 1958 | * @param {Node|Undefined} endNode - optional; if included, the root of the 1959 | * walker will be chosen to ensure it can traverse at least as far as this 1960 | * node. 1961 | * @return {TreeWalker} - a TreeWalker, rooted in a block ancestor of |node|, 1962 | * currently pointing to |node|, which will traverse only visible text and 1963 | * element nodes. 1964 | */ 1965 | const makeWalkerForNode = (node, endNode) => { 1966 | if (!node) { 1967 | return undefined; 1968 | } 1969 | 1970 | // Find a block-level ancestor of the node by walking up the tree. This 1971 | // will be used as the root of the tree walker. 1972 | let blockAncestor = node; 1973 | const endNodeNotNull = endNode != null ? endNode : node; 1974 | while (!blockAncestor.contains(endNodeNotNull) || !isBlock(blockAncestor)) { 1975 | if (blockAncestor.parentNode) { 1976 | blockAncestor = blockAncestor.parentNode; 1977 | } 1978 | } 1979 | const walker = document.createTreeWalker(blockAncestor, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT, node => { 1980 | return internal.acceptNodeIfVisibleInRange(node); 1981 | }); 1982 | walker.currentNode = node; 1983 | return walker; 1984 | }; 1985 | 1986 | /** 1987 | * Modifies the start of the range, if necessary, to ensure the selection text 1988 | * starts after a boundary char (whitespace, etc.) or a block boundary. Can only 1989 | * expand the range, not shrink it. 1990 | * @param {Range} range - the range to be modified 1991 | */ 1992 | const expandRangeStartToWordBound = range => { 1993 | const segmenter = internal.makeNewSegmenter(); 1994 | if (segmenter) { 1995 | // Find the starting text node and offset (since the range may start with a 1996 | // non-text node). 1997 | const startNode = getFirstNodeForBlockSearch(range); 1998 | if (startNode !== range.startContainer) { 1999 | range.setStartBefore(startNode); 2000 | } 2001 | expandToNearestWordBoundaryPointUsingSegments(segmenter, /* expandForward= */false, range); 2002 | } else { 2003 | // Simplest case: If we're in a text node, try to find a boundary char in 2004 | // the same text node. 2005 | const newOffset = findWordStartBoundInTextNode(range.startContainer, range.startOffset); 2006 | if (newOffset !== -1) { 2007 | range.setStart(range.startContainer, newOffset); 2008 | return; 2009 | } 2010 | 2011 | // Also, skip doing any traversal if we're already at the inside edge of 2012 | // a block node. 2013 | if (isBlock(range.startContainer) && range.startOffset === 0) { 2014 | return; 2015 | } 2016 | const walker = makeWalkerForNode(range.startContainer); 2017 | if (!walker) { 2018 | return; 2019 | } 2020 | const finishedSubtrees = new Set(); 2021 | let node = internal.backwardTraverse(walker, finishedSubtrees); 2022 | while (node != null) { 2023 | const newOffset = findWordStartBoundInTextNode(node); 2024 | if (newOffset !== -1) { 2025 | range.setStart(node, newOffset); 2026 | return; 2027 | } 2028 | 2029 | // If |node| is a block node, then we've hit a block boundary, which 2030 | // counts as a word boundary. 2031 | if (isBlock(node)) { 2032 | if (node.contains(range.startContainer)) { 2033 | // If the selection starts inside |node|, then the correct range 2034 | // boundary is the *leading* edge of |node|. 2035 | range.setStart(node, 0); 2036 | } else { 2037 | // Otherwise, |node| is before the selection, so the correct boundary 2038 | // is the *trailing* edge of |node|. 2039 | range.setStartAfter(node); 2040 | } 2041 | return; 2042 | } 2043 | node = internal.backwardTraverse(walker, finishedSubtrees); 2044 | // We should never get here; the walker should eventually hit a block node 2045 | // or the root of the document. Collapse range so the caller can handle 2046 | // this as an error. 2047 | range.collapse(); 2048 | } 2049 | } 2050 | }; 2051 | 2052 | /** 2053 | * Moves the range edges to the first and last visible text nodes inside of it. 2054 | * If there are no visible text nodes in the range then it is collapsed. 2055 | * @param {Range} range - the range to be modified 2056 | */ 2057 | const moveRangeEdgesToTextNodes = range => { 2058 | const firstTextNode = getFirstTextNode(range); 2059 | // No text nodes in range. Collapsing the range and early return. 2060 | if (firstTextNode == null) { 2061 | range.collapse(); 2062 | return; 2063 | } 2064 | const firstNode = getFirstNodeForBlockSearch(range); 2065 | 2066 | // Making sure the range starts with visible text. 2067 | if (firstNode !== firstTextNode) { 2068 | range.setStart(firstTextNode, 0); 2069 | } 2070 | const lastNode = getLastNodeForBlockSearch(range); 2071 | const lastTextNode = getLastTextNode(range); 2072 | // No need for no text node checks here because we know at there's at least 2073 | // firstTextNode in the range. 2074 | 2075 | // Making sure the range ends with visible text. 2076 | if (lastNode !== lastTextNode) { 2077 | range.setEnd(lastTextNode, lastTextNode.textContent.length); 2078 | } 2079 | }; 2080 | 2081 | /** 2082 | * Uses Intl.Segmenter to shift the start or end of a range to a word boundary. 2083 | * Helper method for expandWord*ToWordBound methods. 2084 | * @param {Intl.Segmenter} segmenter - object to use for word segmenting 2085 | * @param {boolean} isRangeEnd - true if the range end should be modified, false 2086 | * if the range start should be modified 2087 | * @param {Range} range - the range to modify 2088 | */ 2089 | const expandToNearestWordBoundaryPointUsingSegments = (segmenter, isRangeEnd, range) => { 2090 | // Find the index as an offset in the full text of the block in which 2091 | // boundary occurs. 2092 | const boundary = isRangeEnd ? { 2093 | node: range.endContainer, 2094 | offset: range.endOffset 2095 | } : { 2096 | node: range.startContainer, 2097 | offset: range.startOffset 2098 | }; 2099 | const nodes = getTextNodesInSameBlock(boundary.node); 2100 | const preNodeText = nodes.preNodes.reduce((prev, cur) => { 2101 | return prev.concat(cur.textContent); 2102 | }, ''); 2103 | const innerNodeText = nodes.innerNodes.reduce((prev, cur) => { 2104 | return prev.concat(cur.textContent); 2105 | }, ''); 2106 | let offsetInText = preNodeText.length; 2107 | if (boundary.node.nodeType === Node.TEXT_NODE) { 2108 | offsetInText += boundary.offset; 2109 | } else if (isRangeEnd) { 2110 | offsetInText += innerNodeText.length; 2111 | } 2112 | 2113 | // Find the segment of the full block text containing the range start. 2114 | const postNodeText = nodes.postNodes.reduce((prev, cur) => { 2115 | return prev.concat(cur.textContent); 2116 | }, ''); 2117 | const allNodes = [...nodes.preNodes, ...nodes.innerNodes, ...nodes.postNodes]; 2118 | 2119 | // Edge case: There's no text nodes in the block. 2120 | // In that case there's nothing to do because there is no word boundary 2121 | // to find. 2122 | if (allNodes.length == 0) { 2123 | return; 2124 | } 2125 | const text = preNodeText.concat(innerNodeText, postNodeText); 2126 | const segments = segmenter.segment(text); 2127 | const foundSegment = segments.containing(offsetInText); 2128 | if (!foundSegment) { 2129 | if (isRangeEnd) { 2130 | range.setEndAfter(allNodes[allNodes.length - 1]); 2131 | } else { 2132 | range.setEndBefore(allNodes[0]); 2133 | } 2134 | return; 2135 | } 2136 | 2137 | // Easy case: if the segment is not word-like (i.e., contains whitespace, 2138 | // punctuation, etc.) then nothing needs to be done because this 2139 | // boundary point is between words. 2140 | if (!foundSegment.isWordLike) { 2141 | return; 2142 | } 2143 | 2144 | // Another easy case: if we are at the first/last character of the 2145 | // segment, then we're done. 2146 | if (offsetInText === foundSegment.index || offsetInText === foundSegment.index + foundSegment.segment.length) { 2147 | return; 2148 | } 2149 | 2150 | // We're inside a word. Based on |isRangeEnd|, the target offset will 2151 | // either be the start or the end of the found segment. 2152 | const desiredOffsetInText = isRangeEnd ? foundSegment.index + foundSegment.segment.length : foundSegment.index; 2153 | let newNodeIndexInText = 0; 2154 | for (const node of allNodes) { 2155 | if (newNodeIndexInText <= desiredOffsetInText && desiredOffsetInText < newNodeIndexInText + node.textContent.length) { 2156 | const offsetInNode = desiredOffsetInText - newNodeIndexInText; 2157 | if (isRangeEnd) { 2158 | if (offsetInNode >= node.textContent.length) { 2159 | range.setEndAfter(node); 2160 | } else { 2161 | range.setEnd(node, offsetInNode); 2162 | } 2163 | } else { 2164 | if (offsetInNode >= node.textContent.length) { 2165 | range.setStartAfter(node); 2166 | } else { 2167 | range.setStart(node, offsetInNode); 2168 | } 2169 | } 2170 | return; 2171 | } 2172 | newNodeIndexInText += node.textContent.length; 2173 | } 2174 | 2175 | // If we got here, then somehow the offset didn't fall within a node. As a 2176 | // fallback, move the range to the start/end of the block. 2177 | if (isRangeEnd) { 2178 | range.setEndAfter(allNodes[allNodes.length - 1]); 2179 | } else { 2180 | range.setStartBefore(allNodes[0]); 2181 | } 2182 | }; 2183 | 2184 | /** 2185 | * @typedef {Object} TextNodeLists - the result of traversing the DOM to 2186 | * extract TextNodes 2187 | * @property {TextNode[]} preNodes - the nodes appearing before a specified 2188 | * starting node 2189 | * @property {TextNode[]} innerNodes - a list containing |node| if it is a 2190 | * text node, or any text node children of |node|. 2191 | * @property {TextNode[]} postNodes - the nodes appearing after a specified 2192 | * starting node 2193 | */ 2194 | 2195 | /** 2196 | * Traverses the DOM to extract all TextNodes appearing in the same block level 2197 | * as |node| (i.e., those that are descendents of a common ancestor of |node| 2198 | * with no other block elements in between.) 2199 | * @param {TextNode} node 2200 | * @return {TextNodeLists} 2201 | */ 2202 | const getTextNodesInSameBlock = node => { 2203 | const preNodes = []; 2204 | // First, backtraverse to get to a block boundary 2205 | const backWalker = makeWalkerForNode(node); 2206 | if (!backWalker) { 2207 | return; 2208 | } 2209 | const finishedSubtrees = new Set(); 2210 | let backNode = internal.backwardTraverse(backWalker, finishedSubtrees); 2211 | while (backNode != null && !isBlock(backNode)) { 2212 | checkTimeout(); 2213 | if (backNode.nodeType === Node.TEXT_NODE) { 2214 | preNodes.push(backNode); 2215 | } 2216 | backNode = internal.backwardTraverse(backWalker, finishedSubtrees); 2217 | } 2218 | preNodes.reverse(); 2219 | const innerNodes = []; 2220 | if (node.nodeType === Node.TEXT_NODE) { 2221 | innerNodes.push(node); 2222 | } else { 2223 | const walker = document.createTreeWalker(node, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT, node => { 2224 | return internal.acceptNodeIfVisibleInRange(node); 2225 | }); 2226 | walker.currentNode = node; 2227 | let child = walker.nextNode(); 2228 | while (child != null) { 2229 | checkTimeout(); 2230 | if (child.nodeType === Node.TEXT_NODE) { 2231 | innerNodes.push(child); 2232 | } 2233 | child = walker.nextNode(); 2234 | } 2235 | } 2236 | const postNodes = []; 2237 | const forwardWalker = makeWalkerForNode(node); 2238 | if (!forwardWalker) { 2239 | return; 2240 | } 2241 | // Forward traverse from node after having finished its subtree 2242 | // to get text nodes after it until we find a block boundary. 2243 | const finishedSubtreesForward = new Set([node]); 2244 | let forwardNode = internal.forwardTraverse(forwardWalker, finishedSubtreesForward); 2245 | while (forwardNode != null && !isBlock(forwardNode)) { 2246 | checkTimeout(); 2247 | if (forwardNode.nodeType === Node.TEXT_NODE) { 2248 | postNodes.push(forwardNode); 2249 | } 2250 | forwardNode = internal.forwardTraverse(forwardWalker, finishedSubtreesForward); 2251 | } 2252 | return { 2253 | preNodes: preNodes, 2254 | innerNodes: innerNodes, 2255 | postNodes: postNodes 2256 | }; 2257 | }; 2258 | 2259 | /** 2260 | * Modifies the end of the range, if necessary, to ensure the selection text 2261 | * ends before a boundary char (whitespace, etc.) or a block boundary. Can only 2262 | * expand the range, not shrink it. 2263 | * @param {Range} range - the range to be modified 2264 | */ 2265 | const expandRangeEndToWordBound = range => { 2266 | const segmenter = internal.makeNewSegmenter(); 2267 | if (segmenter) { 2268 | // Find the ending text node and offset (since the range may end with a 2269 | // non-text node). 2270 | const endNode = getLastNodeForBlockSearch(range); 2271 | if (endNode !== range.endContainer) { 2272 | range.setEndAfter(endNode); 2273 | } 2274 | expandToNearestWordBoundaryPointUsingSegments(segmenter, /* expandForward= */true, range); 2275 | } else { 2276 | let initialOffset = range.endOffset; 2277 | let node = range.endContainer; 2278 | if (node.nodeType === Node.ELEMENT_NODE) { 2279 | if (range.endOffset < node.childNodes.length) { 2280 | node = node.childNodes[range.endOffset]; 2281 | } 2282 | } 2283 | const walker = makeWalkerForNode(node); 2284 | if (!walker) { 2285 | return; 2286 | } 2287 | // We'll traverse the dom after node's subtree to try to find 2288 | // either a word or block boundary. 2289 | const finishedSubtrees = new Set([node]); 2290 | while (node != null) { 2291 | checkTimeout(); 2292 | const newOffset = findWordEndBoundInTextNode(node, initialOffset); 2293 | // Future iterations should not use initialOffset; null it out so it is 2294 | // discarded. 2295 | initialOffset = null; 2296 | if (newOffset !== -1) { 2297 | range.setEnd(node, newOffset); 2298 | return; 2299 | } 2300 | 2301 | // If |node| is a block node, then we've hit a block boundary, which 2302 | // counts as a word boundary. 2303 | if (isBlock(node)) { 2304 | if (node.contains(range.endContainer)) { 2305 | // If the selection starts inside |node|, then the correct range 2306 | // boundary is the *trailing* edge of |node|. 2307 | range.setEnd(node, node.childNodes.length); 2308 | } else { 2309 | // Otherwise, |node| is after the selection, so the correct boundary 2310 | // is the *leading* edge of |node|. 2311 | range.setEndBefore(node); 2312 | } 2313 | return; 2314 | } 2315 | node = internal.forwardTraverse(walker, finishedSubtrees); 2316 | } 2317 | // We should never get here; the walker should eventually hit a block node 2318 | // or the root of the document. Collapse range so the caller can handle this 2319 | // as an error. 2320 | range.collapse(); 2321 | } 2322 | }; 2323 | 2324 | /** 2325 | * Helper to determine if a node is a block element or not. 2326 | * @param {Node} node - the node to evaluate 2327 | * @return {Boolean} - true if the node is an element classified as block-level 2328 | */ 2329 | const isBlock = node => { 2330 | return node.nodeType === Node.ELEMENT_NODE && (internal.BLOCK_ELEMENTS.includes(node.tagName.toUpperCase()) || node.tagName.toUpperCase() === 'HTML' || node.tagName.toUpperCase() === 'BODY'); 2331 | }; 2332 | 2333 | /** 2334 | * Helper to determine if a node is a Text Node or not 2335 | * @param {Node} node - the node to evaluate 2336 | * @return {Boolean} - true if the node is a Text Node 2337 | */ 2338 | const isText = node => { 2339 | return node.nodeType === Node.TEXT_NODE; 2340 | }; 2341 | const forTesting = exports.forTesting = { 2342 | containsBlockBoundary: containsBlockBoundary, 2343 | doGenerateFragment: doGenerateFragment, 2344 | expandRangeEndToWordBound: expandRangeEndToWordBound, 2345 | expandRangeStartToWordBound: expandRangeStartToWordBound, 2346 | findWordEndBoundInTextNode: findWordEndBoundInTextNode, 2347 | findWordStartBoundInTextNode: findWordStartBoundInTextNode, 2348 | FragmentFactory: FragmentFactory, 2349 | getSearchSpaceForEnd: getSearchSpaceForEnd, 2350 | getSearchSpaceForStart: getSearchSpaceForStart, 2351 | getTextNodesInSameBlock: getTextNodesInSameBlock, 2352 | recordStartTime: recordStartTime, 2353 | BlockTextAccumulator: BlockTextAccumulator, 2354 | getFirstTextNode: getFirstTextNode, 2355 | getLastTextNode: getLastTextNode, 2356 | moveRangeEdgesToTextNodes: moveRangeEdgesToTextNodes 2357 | }; 2358 | 2359 | // Allow importing module from closure-compiler projects that haven't migrated 2360 | // to ES6 modules. 2361 | if (typeof goog !== 'undefined') { 2362 | // clang-format off 2363 | goog.declareModuleId('googleChromeLabs.textFragmentPolyfill.fragmentGenerationUtils'); 2364 | // clang-format on 2365 | } 2366 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "__MSG_extension_name__", 3 | "short_name": "__MSG_extension_short_name__", 4 | "version": "2.5.1", 5 | "description": "__MSG_extension_description__", 6 | "background": { 7 | "service_worker": "service_worker.js", 8 | "scripts": ["service_worker.js"], 9 | "type": "module" 10 | }, 11 | "options_ui": { 12 | "page": "options.html", 13 | "open_in_tab": false 14 | }, 15 | "action": {}, 16 | "permissions": [ 17 | "activeTab", 18 | "contextMenus", 19 | "clipboardWrite", 20 | "storage", 21 | "scripting", 22 | "offscreen" 23 | ], 24 | "optional_host_permissions": ["http://*/*", "https://*/*"], 25 | "manifest_version": 3, 26 | "icons": { 27 | "16": "assets/16x16.png", 28 | "32": "assets/32x32.png", 29 | "192": "assets/192x192.png", 30 | "128": "assets/128x128.png", 31 | "180": "assets/180x180.png", 32 | "512": "assets/512x512.png", 33 | "1024": "assets/1024x1024.png" 34 | }, 35 | "default_locale": "en", 36 | "minimum_chrome_version": "88", 37 | "commands": { 38 | "copy_link": { 39 | "description": "__MSG_copy_link__" 40 | } 41 | }, 42 | "browser_specific_settings": { 43 | "gecko": { 44 | "id": "{c13e9f22-6988-4543-86b9-b71bc7e71560}" 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /offscreen.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /offscreen.js: -------------------------------------------------------------------------------- 1 | const browser = chrome || browser; 2 | 3 | const copyLink = async (linkStyle, url, selectedText, html, linkText) => { 4 | // Try to use the Async Clipboard API with fallback to the legacy API. 5 | try { 6 | const { state } = await navigator.permissions.query({ 7 | name: 'clipboard-write', 8 | }); 9 | if (state !== 'granted') { 10 | throw new Error('Clipboard permission not granted'); 11 | } 12 | const clipboardItems = { 13 | 'text/plain': new Blob([url], { type: 'text/plain' }), 14 | }; 15 | if (linkStyle === 'rich') { 16 | clipboardItems['text/html'] = new Blob( 17 | [`${selectedText}`], 18 | { 19 | type: 'text/html', 20 | } 21 | ); 22 | } else if (linkStyle === 'rich_plus_raw') { 23 | clipboardItems['text/html'] = new Blob( 24 | [`${html} ${linkText}`], 25 | { type: 'text/html' } 26 | ); 27 | } 28 | 29 | const clipboardData = [new ClipboardItem(clipboardItems)]; 30 | await navigator.clipboard.write(clipboardData); 31 | } catch (err) { 32 | console.error(err.name, err.message); 33 | const textArea = document.createElement('textarea'); 34 | document.body.append(textArea); 35 | textArea.textContent = url; 36 | textArea.select(); 37 | document.execCommand('copy'); 38 | textArea.remove(); 39 | } 40 | }; 41 | 42 | browser.runtime.onMessage.addListener((message) => { 43 | if (message.target !== 'offscreen') { 44 | return; 45 | } 46 | const { linkStyle, url, selectedText, html, linkText } = message.data; 47 | copyLink(linkStyle, url, selectedText, html, linkText); 48 | return true; 49 | }); 50 | -------------------------------------------------------------------------------- /options.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Options 5 | 6 | 7 | 18 | 19 | 20 |

21 |
22 |

23 | 27 |

28 |

29 | 32 |

33 |

34 | 38 |

39 | 40 |

41 |

42 | 46 |

47 |

48 | 52 |

53 |

54 | 58 |

59 |

60 | 64 | 65 |

66 |
67 | 68 | 69 | 70 | -------------------------------------------------------------------------------- /options.js: -------------------------------------------------------------------------------- 1 | ((browser) => { 2 | document.querySelector('h1').textContent = 3 | browser.i18n.getMessage('link_copy_style'); 4 | document.querySelector('.rich').textContent = browser.i18n.getMessage('rich'); 5 | document.querySelector('.raw').textContent = browser.i18n.getMessage('raw'); 6 | document.querySelector('.rich-plus-raw').textContent = 7 | browser.i18n.getMessage('rich_plus_raw'); 8 | document.querySelector('h4').textContent = 9 | browser.i18n.getMessage('link_text'); 10 | document.querySelector('.link-text-option-1').textContent = 11 | browser.i18n.getMessage('link_text_option_1'); 12 | document.querySelector('.link-text-option-2').textContent = 13 | browser.i18n.getMessage('link_text_option_2'); 14 | document.querySelector('.link-text-option-3').textContent = 15 | browser.i18n.getMessage('link_text_option_3'); 16 | document.querySelector('.link-text-option-4').textContent = 17 | browser.i18n.getMessage('link_text_option_4'); 18 | 19 | const rich = document.querySelector('#rich'); 20 | const raw = document.querySelector('#raw'); 21 | const richPlusRaw = document.querySelector('#rich-plus-raw'); 22 | 23 | const linkTextOption1 = document.querySelector('#link-text-option-1'); 24 | const linkTextOption2 = document.querySelector('#link-text-option-2'); 25 | const linkTextOption3 = document.querySelector('#link-text-option-3'); 26 | const linkTextOption4 = document.querySelector('#link-text-option-4'); 27 | const linkTextInput = document.querySelector('#link-text-input'); 28 | 29 | const enableLinkTextOptions = (enabled) => { 30 | document.getElementsByName('link-text').forEach((e) => { 31 | e.disabled = !enabled; 32 | }); 33 | }; 34 | 35 | // Restore previous settings. 36 | browser.storage.sync.get( 37 | { 38 | linkStyle: 'rich', 39 | linkText: document.querySelector('.link-text-option-1').textContent, 40 | }, 41 | (items) => { 42 | rich.checked = items.linkStyle === 'rich'; 43 | raw.checked = items.linkStyle === 'raw'; 44 | richPlusRaw.checked = items.linkStyle === 'rich_plus_raw'; 45 | linkTextOption1.checked = 46 | items.linkText === 47 | document.querySelector('.link-text-option-1').textContent; 48 | linkTextOption2.checked = 49 | items.linkText === 50 | document.querySelector('.link-text-option-2').textContent; 51 | linkTextOption3.checked = 52 | items.linkText === 53 | document.querySelector('.link-text-option-3').textContent; 54 | if ( 55 | !linkTextOption1.checked && 56 | !linkTextOption2.checked && 57 | !linkTextOption3.checked 58 | ) { 59 | linkTextOption4.checked = true; 60 | linkTextInput.value = items.linkText; 61 | } 62 | enableLinkTextOptions(richPlusRaw.checked); 63 | } 64 | ); 65 | 66 | // Save settings on change. 67 | rich.addEventListener('click', () => { 68 | browser.storage.sync.set({ linkStyle: 'rich' }); 69 | enableLinkTextOptions(richPlusRaw.checked); 70 | }); 71 | raw.addEventListener('click', () => { 72 | browser.storage.sync.set({ linkStyle: 'raw' }); 73 | enableLinkTextOptions(richPlusRaw.checked); 74 | }); 75 | richPlusRaw.addEventListener('click', () => { 76 | browser.storage.sync.set({ linkStyle: 'rich_plus_raw' }); 77 | enableLinkTextOptions(richPlusRaw.checked); 78 | }); 79 | 80 | linkTextOption1.addEventListener('click', () => { 81 | browser.storage.sync.set({ 82 | linkText: document.querySelector('.link-text-option-1').textContent, 83 | }); 84 | }); 85 | linkTextOption2.addEventListener('click', () => { 86 | browser.storage.sync.set({ 87 | linkText: document.querySelector('.link-text-option-2').textContent, 88 | }); 89 | }); 90 | linkTextOption3.addEventListener('click', () => { 91 | browser.storage.sync.set({ 92 | linkText: document.querySelector('.link-text-option-3').textContent, 93 | }); 94 | }); 95 | linkTextOption4.addEventListener('click', () => { 96 | linkTextInput.focus(); 97 | browser.storage.sync.set({ 98 | linkText: linkTextInput.value, 99 | }); 100 | }); 101 | linkTextInput.addEventListener('focus', () => { 102 | linkTextOption4.checked = true; 103 | }); 104 | linkTextInput.addEventListener('change', () => { 105 | if (linkTextInput.value.replace(/\s/g, '').length > 0) { 106 | browser.storage.sync.set({ 107 | linkText: linkTextInput.value, 108 | }); 109 | } else { 110 | browser.storage.sync.set({ 111 | linkText: document.querySelector('.link-text-option-1').textContent, 112 | }); 113 | } 114 | }); 115 | })(chrome || browser); 116 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "link-to-text-fragment", 3 | "version": "2.5.1", 4 | "description": "Browser extension that allows for linking to arbitrary text fragments.", 5 | "type": "module", 6 | "scripts": { 7 | "fix": "npx prettier --write .", 8 | "lint": "npx eslint ./*.js --fix", 9 | "prepare": "npm run fix && npm run lint && npx rollup ./node_modules/text-fragments-polyfill/src/fragment-generation-utils.js --dir . && npx babel ./fragment-generation-utils.js --out-file ./fragment-generation-utils.js --plugins @babel/plugin-transform-modules-commonjs", 10 | "safari": "xcrun safari-web-extension-converter . --project-location ../safari-extensions/link-to-text-fragment --copy-resources --swift --force --bundle-identifier com.google.googlechromelabs.link-to-text-fragment" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/GoogleChromeLabs/link-to-text-fragment.git" 15 | }, 16 | "keywords": [ 17 | "text fragment", 18 | "link to text fragment", 19 | "scroll to text fragment" 20 | ], 21 | "author": "Thomas Steiner (https://blog.tomayac.com/)", 22 | "license": "Apache-2.0", 23 | "bugs": { 24 | "url": "https://github.com/GoogleChromeLabs/link-to-text-fragment/issues" 25 | }, 26 | "homepage": "https://github.com/GoogleChromeLabs/link-to-text-fragment#readme", 27 | "devDependencies": { 28 | "@babel/cli": "^7.26.4", 29 | "@babel/core": "^7.26.7", 30 | "@babel/plugin-transform-modules-commonjs": "^7.26.3", 31 | "@babel/preset-env": "^7.26.7", 32 | "@babel/runtime-corejs3": "^7.26.7", 33 | "core-js": "^3.40.0", 34 | "eslint": "^9.19.0", 35 | "eslint-config-google": "^0.14.0", 36 | "prettier": "^3.4.2", 37 | "rollup": "^4.34.2", 38 | "shx": "^0.3.4", 39 | "text-fragments-polyfill": "6.3.0" 40 | }, 41 | "eslintConfig": { 42 | "env": { 43 | "es6": true, 44 | "browser": true 45 | }, 46 | "parserOptions": { 47 | "ecmaVersion": 2020, 48 | "sourceType": "module" 49 | }, 50 | "extends": [ 51 | "eslint:recommended", 52 | "google" 53 | ] 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /prepare.js: -------------------------------------------------------------------------------- 1 | const exports = {}; 2 | -------------------------------------------------------------------------------- /privacy.md: -------------------------------------------------------------------------------- 1 | This product does not access, collect, or transmit any data. 2 | -------------------------------------------------------------------------------- /service_worker.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2020 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | const browser = chrome || browser; 18 | const DEBUG = false; 19 | 20 | // A global promise to avoid concurrency issues 21 | let creating; 22 | const path = 'offscreen.html'; 23 | 24 | const setupOffscreenDocument = async (path) => { 25 | // Check all windows controlled by the service worker to see if one 26 | // of them is the offscreen document with the given path 27 | const offscreenUrl = browser.runtime.getURL(path); 28 | 29 | const matchedClients = await clients.matchAll(); 30 | for (const client of matchedClients) { 31 | if (client.url === offscreenUrl) { 32 | return; 33 | } 34 | } 35 | 36 | // Create offscreen document 37 | if (creating) { 38 | await creating; 39 | } else { 40 | creating = browser.offscreen.createDocument({ 41 | url: path, 42 | reasons: ['CLIPBOARD'], 43 | justification: 'Clipboard access is required to copy the link.', 44 | }); 45 | await creating; 46 | creating = null; 47 | } 48 | }; 49 | 50 | browser.action.onClicked.addListener((info, tab) => onCopy(info, tab)); 51 | 52 | const log = (...args) => { 53 | if (DEBUG) { 54 | console.log(...args); 55 | } 56 | }; 57 | 58 | const injectContentScripts = async (contentScriptNames) => { 59 | // If there's a reply, the content script already was injected. 60 | try { 61 | return await sendMessageToPage('ping'); 62 | } catch (err) { 63 | await Promise.all( 64 | contentScriptNames.map((contentScriptName) => { 65 | return new Promise((resolve) => { 66 | browser.tabs.query( 67 | { 68 | active: true, 69 | currentWindow: true, 70 | }, 71 | (tabs) => { 72 | browser.scripting.executeScript( 73 | { 74 | files: [contentScriptName], 75 | target: { 76 | tabId: tabs[0].id, 77 | }, 78 | }, 79 | () => { 80 | log('Injected content script', contentScriptName); 81 | return resolve(); 82 | } 83 | ); 84 | } 85 | ); 86 | }); 87 | }) 88 | ); 89 | } 90 | }; 91 | 92 | browser.contextMenus.create( 93 | { 94 | title: browser.i18n.getMessage('copy_link'), 95 | id: 'copy-link', 96 | contexts: ['selection'], 97 | }, 98 | () => { 99 | if (browser.runtime.lastError) { 100 | console.log( 101 | 'Error creating context menu item:', 102 | browser.runtime.lastError 103 | ); 104 | } 105 | } 106 | ); 107 | 108 | browser.commands.onCommand.addListener(async (command) => { 109 | if (command === 'copy_link') { 110 | await injectContentScripts([ 111 | 'prepare.js', 112 | 'fragment-generation-utils.js', 113 | 'content_script.js', 114 | ]); 115 | browser.tabs.query( 116 | { 117 | active: true, 118 | currentWindow: true, 119 | }, 120 | (tabs) => { 121 | startProcessing(tabs[0]); 122 | } 123 | ); 124 | } 125 | }); 126 | browser.contextMenus.onClicked.addListener((info, tab) => onCopy(info, tab)); 127 | 128 | const startProcessing = async (tab) => { 129 | try { 130 | await sendMessageToPage('debug', DEBUG); 131 | } catch { 132 | // Ignore 133 | } 134 | await sendMessageToPage('create-text-fragment'); 135 | }; 136 | 137 | const sendMessageToPage = (message, data = null) => { 138 | return new Promise((resolve, reject) => { 139 | browser.tabs.query( 140 | { 141 | active: true, 142 | currentWindow: true, 143 | }, 144 | (tabs) => { 145 | browser.tabs.sendMessage( 146 | tabs[0].id, 147 | { 148 | message, 149 | data, 150 | }, 151 | (response) => { 152 | if (!response) { 153 | return reject( 154 | new Error('Failed to connect to the specified tab.') 155 | ); 156 | } 157 | return resolve(response); 158 | } 159 | ); 160 | } 161 | ); 162 | }); 163 | }; 164 | 165 | const onCopy = async (info, tab) => { 166 | Promise.all([ 167 | await setupOffscreenDocument(path), 168 | await injectContentScripts([ 169 | 'prepare.js', 170 | 'fragment-generation-utils.js', 171 | 'content_script.js', 172 | ]), 173 | ]); 174 | startProcessing(tab); 175 | }; 176 | -------------------------------------------------------------------------------- /store-assets/icon-128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleChromeLabs/link-to-text-fragment/11d85b83b7bc6265b621bc6f48ab52fc530573bc/store-assets/icon-128x128.png -------------------------------------------------------------------------------- /store-assets/promo-tile-440x280.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleChromeLabs/link-to-text-fragment/11d85b83b7bc6265b621bc6f48ab52fc530573bc/store-assets/promo-tile-440x280.png -------------------------------------------------------------------------------- /store-assets/screenshot-contextmenu-1280x800.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleChromeLabs/link-to-text-fragment/11d85b83b7bc6265b621bc6f48ab52fc530573bc/store-assets/screenshot-contextmenu-1280x800.png --------------------------------------------------------------------------------