text-fragments-ts/src/ polyfill.ts
125 lines
5.5 KiB

  1. // A polyfill that makes the browser scroll to, and highlight, the Text Fragment given in the location’s fragment directive.
  2. // See https://wicg.github.io/scroll-to-text-fragment/
  3. // Based on the version of 12 August 2020. <https://raw.githubusercontent.com/WICG/scroll-to-text-fragment/60f5f63b4997bde7e688cacf897e1167c622e100/index.html>
  4. // This implementation assumes the browser has already performed the normal procedures to identify and scroll to the fragment, without support for Text Fragments.
  5. import {
  6. initializeDocumentFragmentDirective,
  7. indicatedPartOfTheDocument_beginning,
  8. scrollToTheFragment,
  9. FragmentDirective,
  10. browserSupportsTextFragments,
  11. } from './index.js';
  12. function run(): void {
  13. const { documentUrl, documentFragmentDirective } = initializeDocumentFragmentDirective(document) ?? {};
  14. if (documentUrl !== document.URL) {
  15. // We could change the location to hide the fragment directive from the fragment, as the spec prescribes; however this would also hide it from the user (and could trigger other event listeners).
  16. // document.location.replace(documentUrl);
  17. }
  18. applyFragmentDirective({ document, documentFragmentDirective });
  19. }
  20. function applyFragmentDirective({ document, documentFragmentDirective } : {
  21. document: Document,
  22. documentFragmentDirective: string | null,
  23. }): void {
  24. if (documentFragmentDirective !== null) {
  25. const { documentIndicatedPart, ranges } = indicatedPartOfTheDocument_beginning({
  26. document,
  27. documentFragmentDirective,
  28. documentAllowTextFragmentDirective: true, // TEMP (TODO should be determined if possible)
  29. }) || undefined;
  30. if (documentIndicatedPart !== undefined) {
  31. scrollToTheFragment(documentIndicatedPart);
  32. }
  33. if (ranges !== undefined) {
  34. highlightRanges(ranges);
  35. }
  36. }
  37. }
  38. function pretendBrowserSupportsTextFragments(): void {
  39. const fragmentDirective: FragmentDirective = {};
  40. // Sneak in a note so one can discover whether the polyfill is used.
  41. Object.defineProperty(fragmentDirective, '_implementation', {
  42. value: 'text-fragments-ts',
  43. enumerable: false,
  44. });
  45. Object.defineProperty(document, 'fragmentDirective', {
  46. value: fragmentDirective,
  47. writable: false,
  48. });
  49. }
  50. // See § 3.6. Indicating The Text Match <https://wicg.github.io/scroll-to-text-fragment/#indicating-the-text-match>
  51. // This implements a simple method to highlight the indicated ranges, without modifying the DOM: we use the window’s selection. This has the limitation that it disappears as soon as the user clicks anywhere; but the ability to dismiss it is a feature too; and it helps convey that the highlight is not part of the page itself.
  52. // Note the spec urges against this approach: “the UA must not use the Document’s selection to indicate the text match as doing so could allow attack vectors for content exfiltration.”
  53. // XXX How exactly could this be an attack vector?
  54. function highlightRanges(ranges: Range[]): void {
  55. const selection = window.getSelection() as Selection; // should be non-null on top window.
  56. selection.removeAllRanges();
  57. for (const range of ranges) {
  58. selection.addRange(range);
  59. }
  60. }
  61. // Listen to link clicks, and activate the polyfill when a link points to a fragment within the same page.
  62. function addLinkClickListeners() {
  63. const linkElements = [
  64. ...document.getElementsByTagName('a'),
  65. ...document.getElementsByTagName('area'),
  66. ]
  67. linkElements.forEach(element => {
  68. element.addEventListener('click', () => {
  69. if (element.href.split('#')[0] === document.URL.split('#')[0]) {
  70. const fragId = element.href.split('#')[1];
  71. if (fragId && fragId.includes(':~:')) {
  72. const fragmentDirective = fragId.substring(fragId.indexOf(':~:') + 3);
  73. applyFragmentDirective({
  74. document,
  75. documentFragmentDirective: fragmentDirective,
  76. });
  77. }
  78. }
  79. });
  80. });
  81. }
  82. function install(): void {
  83. if (browserSupportsTextFragments()) {
  84. // Chromium’s implementation currently does not trigger when clicking links pointing to quotes within the same page. Use this as a workaround (listening to hashchange won’t help, as we cannot access the fragment directive).
  85. addLinkClickListeners();
  86. return;
  87. }
  88. pretendBrowserSupportsTextFragments();
  89. // Run when the page is ready.
  90. window.addEventListener('load', run);
  91. // Could we somehow avoid activating in cases where the browser would retain scroll position, e.g. on page reload or history navigation?
  92. // Run whenever the location’s fragment identifier is changed.
  93. window.addEventListener('hashchange', run);
  94. // Could we somehow also detect it when the user navigates to exactly the same fragment again? (to mimic browser/Firefox’s behaviour when just pressing enter in the URL bar)
  95. }
  96. install();
  97. // A small tool to use from e.g. the browser console.
  98. export function applyFragDir(fragmentDirective: string) {
  99. if (typeof fragmentDirective !== 'string' || !fragmentDirective.includes(':~:'))
  100. throw new TypeError('Expected a fragment directive string, e.g. ":~:text=bla&text=blub"');
  101. fragmentDirective = fragmentDirective.substring(fragmentDirective.indexOf(':~:') + 3);
  102. applyFragmentDirective({
  103. document,
  104. documentFragmentDirective: fragmentDirective,
  105. });
  106. }