Kanka Automatic Table of Contents

Automatically adds a table of contents to Kanka entity pages under the Pins sidebar.

当前为 2024-09-29 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name Kanka Automatic Table of Contents
  3. // @namespace http://tampermonkey.net/
  4. // @version 11
  5. // @description Automatically adds a table of contents to Kanka entity pages under the Pins sidebar.
  6. // @author Salvatos
  7. // @match https://app.kanka.io/*
  8. // @exclude */html-export*
  9. // @icon https://www.google.com/s2/favicons?domain=kanka.io
  10. // @grant GM_addStyle
  11. // ==/UserScript==
  12.  
  13. // Run only on entity Story pages
  14. if (document.getElementById('app').parentNode.classList.contains("entity-story")) {
  15. /* Preferences */
  16. const addTopLink = "";
  17.  
  18. /* Set arrays */
  19. var headings = [];
  20. var tag_names = { h1:1, h2:1, h3:1, h4:1, h5:1, h6:1 };
  21.  
  22. /* Pre-cleaning: remove stray line breaks left by Summernote at the end of headings so our TOC link doesn't get pushed to a new line */
  23. $('h1 br:last-child, h2 br:last-child, h3 br:last-child, h4 br:last-child, h5 br:last-child, h6 br:last-child').remove();
  24.  
  25. /* Walks through DOM looking for selected elements */
  26. function walk( root ) {
  27. if( root.nodeType === 1 && root.nodeName !== 'script' && !root.classList.contains("calendar") && !root.classList.contains("modal") ) { // Added a check to exclude modals and calendar dates
  28. if( tag_names.hasOwnProperty(root.nodeName.toLowerCase()) ) {
  29. headings.push( root );
  30. } else {
  31. for( var i = 0; i < root.childNodes.length; i++ ) {
  32. walk( root.childNodes[i] );
  33. }
  34. }
  35. }
  36. }
  37. // Find and walk through the main content block
  38. walk( document.getElementsByClassName('entity-main-block')[0] );
  39.  
  40. /* Start main list */
  41. var level = 0;
  42. var past_level = 0;
  43. var hList = `
  44. <div id='toc' class='sidebar-section-box overflow-hidden flex flex-col gap-2'>
  45. <div class="sidebar-section-title cursor-pointer text-lg user-select border-b element-toggle" data-animate="collapse" data-target="#sidebar-toc-list" onclick="this.classList.toggle('animate-collapsed'); document.getElementById('sidebar-toc-list').classList.toggle('hidden');">
  46. <i class="fa-solid fa-chevron-up icon-show" aria-hidden="true"></i>
  47. <i class="fa-solid fa-chevron-down icon-hide" aria-hidden="true"></i>
  48. Table of contents
  49. </div>
  50. <div class="sidebar-elements overflow-hidden" id="sidebar-toc-list">
  51. <div class="flex flex-col gap-2 text-xs">
  52. <ul id='tableofcontents'>
  53. <li class='toc-level-0'><a href='#toc-entry'>Entry</a></li>
  54. `;
  55.  
  56. /* Create sublists to reflect heading level */
  57. for( var i = 0; i < headings.length; i++ ) {
  58. // "Entry" and post titles act as level-0 headers;
  59. level = (headings[i].classList.contains("post-title")) ? 0 : headings[i].nodeName.substr(1);
  60.  
  61. if (level > past_level) { // Go down a level
  62. for(var j = 0; j < level - past_level; j++) {
  63. hList += "<li><ul>";
  64. }
  65. }
  66. else if (level < past_level) { // Go up a level
  67. for(var j = 0; j < past_level - level; j++) {
  68. hList += "</ul></li>";
  69. }
  70. }
  71.  
  72. /* Handle heading text (it gets complicated with Timeline elements and inline tags) */
  73. var headingText = headings[i],
  74. child = headingText.firstChild,
  75. texts = [];
  76. // Iterate through heading nodes
  77. while (child) {
  78. // Not a tag (text node)
  79. if (!child.tagName) {
  80. texts.push(child.data);
  81. console.log("1: " + child.data); // Why am I getting so many empty text nodes?
  82. }
  83. // Identify and manage HTML tags
  84. else {
  85. // Text-muted tag, i.e. a Timeline date ;; no longer relevant but keeping for reference
  86. /*
  87. if ($(child).hasClass("text-muted")) {
  88. //texts.push('<span class="text-muted">' + child.innerText + '</span>');
  89. texts.push(child.innerText);
  90. console.log("2: " + child.innerText);
  91. }
  92. */
  93. // Screenreader prompt
  94. if ($(child).hasClass("sr-only")) {
  95. // exclude
  96. }
  97. else {
  98. texts.push(child.innerText);
  99. console.log("3: " + child.innerText);
  100. }
  101. }
  102. child = child.nextSibling;
  103. }
  104.  
  105. headingText = texts.join("");
  106.  
  107. /* Add an ID to the entry box */
  108. document.querySelector(".box-entity-entry").id = "toc-entry";
  109.  
  110. /* Check if heading already has an ID, else create one */
  111. if (headings[i].id.length < 1) {
  112. headings[i].id = "h" + i + "-" + headingText.trim().replace(/\s+/g, "-").replace(/^[^\p{L}]+|[^\p{L}\p{N}:.-]+/gu, "");
  113. // We indicate a unique ID to acccount for duplicate titles
  114. }
  115.  
  116. /* Create link in TOC */
  117. hList += "<li class='toc-level-" + level + "'><a href='#" + headings[i].id + "' parent-post='" + $(headings[i]).closest('article:is(.box-entity-entry, .post-block)').attr('id') + "'>" + headingText + "</a></li>";
  118.  
  119. /* Add "toc" link to non-box headings */
  120. if (addTopLink && level > 0 && $(headings[i]).parent('a.entity-mention').length == 0) { // That last condition is to omit Extraordinary Tooltips and other transclusions
  121. headings[i].insertAdjacentHTML("beforeend", "<a class='to-top' href='#toc' title='Back to table of contents'>&nbsp;^&nbsp;" + addTopLink + "</a>");
  122. }
  123.  
  124. /* Update past_level */
  125. past_level = level;
  126. }
  127.  
  128. /* Close sublists per current level */
  129. for(var k = 0; k < past_level; k++) {
  130. hList += "</li></ul>";
  131. }
  132. /* Close TOC */
  133. hList += "</div></div></div>";
  134.  
  135. /* Insert element after Pins (and entity links) */
  136. /* Calendars use only one sidebar */
  137. if (document.getElementById('app').parentNode.classList.contains("kanka-entity-calendar")) {
  138. document.getElementsByClassName('entity-submenu')[0].insertAdjacentHTML("beforeend", hList);
  139. }
  140. /* Everything else */
  141. else {
  142. document.getElementsByClassName('entity-sidebar')[0].insertAdjacentHTML("beforeend", hList);
  143. }
  144.  
  145. /* Listener: If the target heading is in a collapsed post, expand it first */
  146. // For headings within posts, we need to find the parent to open, then scroll to the targeted heading once rendered
  147. $("#tableofcontents :not(.toc-level-0) a").click(function() {
  148. var targetPost = $(this).attr('parent-post');
  149. if (targetPost != "toc-entry" && $("#" + targetPost + " .element-toggle")[0].classList.contains("animate-collapsed")) {
  150. $("#" + targetPost + " .element-toggle")[0].click();
  151.  
  152. // Wait a bit for rendering and scroll to appropriate heading
  153. let targetHeading = document.querySelector($(this).attr('href'));
  154. setTimeout(function(){ targetHeading.scrollIntoView(); }, 300);
  155. }
  156. });
  157. // For posts, just pop them open as we go
  158. $("#tableofcontents .toc-level-0 a").click(function() {
  159. var targetPost = $(this).attr('parent-post');
  160. if (targetPost != "undefined" && $("#" + targetPost + " .element-toggle")[0].classList.contains("animate-collapsed")) {
  161. $("#" + targetPost + " .element-toggle")[0].click();
  162. }
  163. });
  164.  
  165. GM_addStyle(`
  166. .entity-links {
  167. margin-bottom: 30px; /* For consistency with other boxes */
  168. }
  169. #tableofcontents, #tableofcontents ul {
  170. list-style: none;
  171. padding: 0;
  172. text-indent: -5px;
  173. }
  174. #tableofcontents {
  175. padding: 5px 10px;
  176. margin: 0;
  177. overflow: hidden;
  178. word-wrap: anywhere;
  179. }
  180. #tableofcontents ul {
  181. padding-left: 10px;
  182. }
  183. #tableofcontents a {
  184. font-size: 13px;
  185. }
  186. #tableofcontents li.toc-level-0 a {
  187. font-weight: bold;
  188. }
  189. .to-top {
  190. vertical-align: super;
  191. font-variant: all-petite-caps;
  192. font-size: 10px;
  193. }
  194. `);
  195. }