Kanka Automatic Table of Contents

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

  1. // ==UserScript==
  2. // @name Kanka Automatic Table of Contents
  3. // @namespace http://tampermonkey.net/
  4. // @version 12
  5. // @description Automatically adds a table of contents to Kanka entity pages under the Pins sidebar.
  6. // @author Salvatos
  7. // @license MIT
  8. // @match https://app.kanka.io/*
  9. // @exclude */html-export*
  10. // @icon https://www.google.com/s2/favicons?domain=kanka.io
  11. // @grant GM_addStyle
  12. // ==/UserScript==
  13.  
  14. // Run only on entity Story pages
  15. if (document.body.classList.contains("entity-story")) {
  16. /* Preferences */
  17. const stickyTOC = true; // true or false
  18. const addTopLink = ""; // "your text" or "" for no link back to ToC after headings
  19. const classExclusions = ["calendar", "modal", "box-entity-attributes", "toc-ignore"]; // Comma-delimited list of HTML classes to ignore
  20. // Out of the box: calendar tables, modals, character sheets, sections below entry & posts
  21.  
  22. /* Set arrays */
  23. var headings = [];
  24. const tag_names = ["h1", "h2", "h3", "h4", "h5", "h6"]
  25.  
  26. /* 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 */
  27. document.querySelectorAll(':is(h1, h2, h3, h4, h5, h6) br:last-child').forEach( (br) => br.remove() );
  28. /* Pre-cleaning: tag out headers in sections other than entry and posts, and hidden transcluded content */
  29. document.querySelectorAll(`.row-add-note-button + div :is(h1, h2, h3, h4, h5, h6),
  30. .mention-entry-content .no-transclusion`).forEach( (ex) => ex.classList.add("toc-ignore") );
  31.  
  32. /* Walks through DOM looking for selected elements */
  33. function walk( root ) {
  34. // Make sure the node is a valid element and skip unwanted classes
  35. console.log(root);
  36. if( root.nodeType === 1 && root.nodeName !== 'script' && classExclusions.every( (c) => !root.classList.contains(c) ) ) {
  37. if( tag_names.includes(root.nodeName.toLowerCase()) ) { // Any H tag gets added; don’t delve further
  38. headings.push( root );
  39. } else { // Walk through descendants
  40. // Add an entry wherever we find the Entry box
  41. if (root.classList.contains("box-entity-entry")) {
  42. headings.push( root );
  43. }
  44. for( var i = 0; i < root.childNodes.length; i++ ) {
  45. walk( root.childNodes[i] );
  46. }
  47. }
  48. }
  49. }
  50. // Find and walk through the main content block
  51. walk( document.querySelector('.entity-main-block') );
  52.  
  53. /* Start main list */
  54. var level = 0, past_level = 0;
  55. var hList = `
  56. <div id='toc' class='sidebar-section-box overflow-hidden flex flex-col gap-2'>
  57. <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');">
  58. <i class="fa-solid fa-chevron-up icon-show" aria-hidden="true"></i>
  59. <i class="fa-solid fa-chevron-down icon-hide" aria-hidden="true"></i>
  60. Table of contents
  61. </div>
  62. <div class="sidebar-elements" id="sidebar-toc-list">
  63. <div class="flex flex-col gap-2 text-xs">
  64. <ul id='tableofcontents'>
  65. `;
  66.  
  67. /* Create sublists to reflect heading level */
  68. for( var i = 0; i < headings.length; i++ ) {
  69. // Entry, post and era titles act as level-0 headers; timeline events as level 1; everything else per its H tag
  70. level = ( headings[i].classList.contains("post-title") || headings[i].classList.contains("box-entity-entry") || headings[i].parentElement.classList.contains("timeline-era-head") || headings[i].parentElement.querySelector(".post-buttons") ) ? 0 : ( headings[i].parentElement.classList.contains("timeline-item-head") ) ? 1 : headings[i].nodeName.substr(1);
  71.  
  72. if (level > past_level) { // Go down a level
  73. for(var j = 0; j < level - past_level; j++) {
  74. hList += "<li><ul>";
  75. }
  76. }
  77. else if (level < past_level) { // Go up a level
  78. for(var j = 0; j < past_level - level; j++) {
  79. hList += "</ul></li>";
  80. }
  81. }
  82.  
  83. /* Handle heading text (it gets complicated with Timeline elements and inline tags, so we can’t just innerText it) */
  84. if (headings[i].classList.contains("box-entity-entry")) {
  85. headingText = "Entry";
  86. }
  87. else {
  88. var headingText = headings[i],
  89. child = headingText.firstChild,
  90. texts = [];
  91. // Iterate through heading nodes
  92. while (child) {
  93. // Not a tag (text node)
  94. if (!child.tagName) {
  95. texts.push(child.data);
  96. //console.log("1: " + child.data); // Why am I getting so many empty text nodes?
  97. }
  98. // Identify and manage HTML tags
  99. else {
  100. // Text-muted tag, i.e. a Timeline date ;; no longer relevant but keeping for reference
  101. /*
  102. if (child.classList.contains("text-muted")) {
  103. //texts.push('<span class="text-muted">' + child.innerText + '</span>');
  104. texts.push(child.innerText);
  105. console.log("2: " + child.innerText);
  106. }
  107. */
  108. // Screenreader prompt
  109. if (child.classList.contains("sr-only")) {
  110. // exclude
  111. }
  112. // Push text
  113. else {
  114. texts.push(child.innerText);
  115. //console.log("3: " + child.innerText);
  116. }
  117. }
  118. child = child.nextSibling;
  119. }
  120.  
  121. headingText = texts.join("");
  122. }
  123.  
  124. // Ignore empty H tags, which Summernote sometimes leaves behind; for everything else, proceed
  125. if (headingText.length > 0) {
  126. /* Add an ID to the Entry box so we can link to it */
  127. if (document.querySelector(".box-entity-entry")) { // In rare cases, there is none (i.e. after saving an empty entry in Code View)
  128. document.querySelector(".box-entity-entry").id = "toc-entry";
  129. }
  130.  
  131. /* Check if heading already has an ID, else create one */
  132. if (headings[i].id.length < 1) {
  133. headings[i].id = "h" + i + "-" + headingText.trim().replace(/\s+/g, "-").replace(/^[^\p{L}]+|[^\p{L}\p{N}:.-]+/gu, "");
  134. // Index included to ensure a unique ID with duplicate titles
  135. }
  136.  
  137. /* Create link in TOC */
  138. var parentId, parentEra;
  139. // Timelines require special handling since they have different markup and a collapsed event can be in a collapsed era
  140. // Event
  141. if ( headings[i].closest('li[id|="timeline-element"]') ) {
  142. parentId = headings[i].closest('li[id|="timeline-element"]').id;
  143. parentEra = "era" + headings[i].closest('ul[id|="era-items"]').id.match(/\d+/);
  144. }
  145. // Era
  146. else if ( headings[i].closest('div.timeline-era') ) {
  147. parentId = headings[i].closest('div.timeline-era').id;
  148. }
  149. // Post or entry
  150. else {
  151. if (headings[i].closest('article:is(.box-entity-entry, .post-block)')) {
  152. parentId = headings[i].closest('article:is(.box-entity-entry, .post-block)').id;
  153. }
  154. // Special posts like character sheets or relations are in non-collapsible divs; treat the heading as its own target
  155. else {
  156. parentId = headings[i].id
  157. }
  158. }
  159. hList += "<li class='toc-level-" + level + "'><a href='#" + headings[i].id + "' data-parent-post='" + parentId + "'" + ((parentEra) ? "data-parent-era='" + parentEra + "'" : "") + ">" + headingText + "</a></li>";
  160.  
  161. /* Add "toc" link to non-box headings */
  162. if (addTopLink && level > 0 && !headings[i].parentElement.classList.contains("entity-mention")) { // That last condition is to omit Extraordinary Tooltips and other transclusions
  163. headings[i].insertAdjacentHTML("beforeend", "<a class='to-top' href='#toc' title='Back to table of contents'>&nbsp;^&nbsp;" + addTopLink + "</a>");
  164. }
  165.  
  166. /* Update past_level */
  167. past_level = level;
  168. }
  169. }
  170.  
  171. /* Close sublists per current level */
  172. for(var k = 0; k < past_level; k++) {
  173. hList += "</li></ul>";
  174. }
  175. /* Close TOC */
  176. hList += "</div></div></div>";
  177.  
  178. // Final check: if we haven’t added a single item yet, don’t add the ToC to the DOM (no entry, post or era)
  179. if ( hList.match(/<li/) ) {
  180. /* Insert element after History block */
  181. /* Calendars use only one sidebar */
  182. if (document.body.classList.contains("kanka-entity-calendar")) {
  183. document.querySelector('.entity-submenu > div').insertAdjacentHTML("beforeend", hList);
  184. }
  185. /* Everything else */
  186. else {
  187. document.querySelector('.entity-sidebar').insertAdjacentHTML("beforeend", hList);
  188. }
  189.  
  190. // Sticky block
  191. if (stickyTOC) {
  192. document.getElementById("toc").style = "position: sticky;top: 4.25em;max-height: calc(100vh - 5.5em);overflow-y: auto;";
  193. document.getElementById("sidebar-toc-list").style = "overflow-y: auto;";
  194. }
  195.  
  196. /* Listener: If the target heading is in a collapsed post, expand it first */
  197. // For headings within posts, we need to find the parent to open, then scroll to the targeted heading once rendered
  198. document.querySelectorAll("#tableofcontents :not(.toc-level-0) a").forEach( (anchor) => {
  199. anchor.addEventListener('click', (event) => {
  200. var targetPost = event.target.dataset.parentPost,
  201. targetEra = event.target.dataset.parentEra;
  202. // Check that a toggle exists first; special posts don’t have one
  203. if (document.querySelector("#" + targetPost + " .element-toggle") && document.querySelector("#" + targetPost + " .element-toggle").classList.contains("animate-collapsed")) {
  204. document.querySelector("#" + targetPost + " .element-toggle").click();
  205. }
  206. // If the target is a Timeline event, we also need the parent era to be expanded
  207. if (targetEra && document.querySelector("#" + targetEra + " .element-toggle").classList.contains("animate-collapsed")) {
  208. document.querySelector("#" + targetEra + " .element-toggle").click();
  209. }
  210.  
  211. // Wait a bit for rendering and scroll to appropriate heading
  212. let targetHeading = document.querySelector(event.target.getAttribute("href"));
  213. setTimeout(function(){ targetHeading.scrollIntoView(); }, 300);
  214. });
  215. });
  216. // For direct links to posts and timeline eras, just pop them open as we go
  217. document.querySelectorAll("#tableofcontents .toc-level-0 a:not([href='#toc-entry'])").forEach( (anchor) => {
  218. anchor.addEventListener('click', (event) => {
  219. var targetPost = event.target.dataset.parentPost;
  220. // Check that a toggle exists first; special posts don’t have one
  221. if (document.querySelector("#" + targetPost + " .element-toggle") && document.querySelector("#" + targetPost + " .element-toggle").classList.contains("animate-collapsed")) {
  222. document.querySelector("#" + targetPost + " .element-toggle").click();
  223. }
  224. });
  225. });
  226. }
  227.  
  228. GM_addStyle(`
  229. #tableofcontents {
  230. padding: 5px 0;
  231. margin: 0;
  232. list-style: none;
  233. overflow: hidden;
  234. overflow-wrap: anywhere;
  235.  
  236. ul {
  237. padding: 0 0 0 5px;
  238. margin-bottom: 2px;
  239. list-style: none;
  240.  
  241. li {
  242. padding-left: 5px;
  243. hyphens: auto
  244. }
  245.  
  246. li:not(:has(li))::marker {
  247. content: "⟡";
  248. }
  249. }
  250.  
  251. a {
  252. font-size: 13px;
  253. }
  254.  
  255. li.toc-level-0 a {
  256. font-weight: bold;
  257. }
  258. }
  259. .to-top {
  260. vertical-align: super;
  261. font-variant: all-petite-caps;
  262. font-size: 10px;
  263. }
  264. `);
  265. }