├── .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 | 
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 |
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
--------------------------------------------------------------------------------