KaTeXFlowy-with-AsciiMath

Supports formula rendering in WorkFlowy with KaTeX. Also supports AsciiMath.

  1. // ==UserScript==
  2. // @name KaTeXFlowy-with-AsciiMath
  3. // @namespace https://github.com/BettyJJ
  4. // @version 0.3.1+am
  5. // @description Supports formula rendering in WorkFlowy with KaTeX. Also supports AsciiMath.
  6. // @author Betty
  7. // @match https://workflowy.com/*
  8. // @match https://*.workflowy.com/*
  9. // @run-at document-idle
  10. // @grant GM.addStyle
  11. // @grant GM_getResourceText
  12. // @resource KATEX_CSS https://cdn.jsdelivr.net/npm/katex@0.15.2/dist/katex.min.css
  13. // @require https://cdn.jsdelivr.net/npm/katex@0.15.2/dist/katex.min.js
  14. // @require https://cdn.jsdelivr.net/npm/katex@0.15.2/dist/contrib/auto-render.min.js
  15. // @require https://unpkg.com/asciimath2tex@1.2.1/dist/asciimath2tex.umd.js
  16. // ==/UserScript==
  17.  
  18. (function () {
  19. 'use strict';
  20.  
  21.  
  22. init();
  23.  
  24.  
  25. /**
  26. * initialize
  27. */
  28. function init() {
  29. watch_page();
  30.  
  31. load_css();
  32.  
  33. hide_raw();
  34.  
  35. // WF's default is to show the first line of a note when it's not in focus. This function hides the note completely if it contains formula and is not in focus. Comment out this line if you don't like this behavior
  36. hide_note_raw();
  37.  
  38. }
  39.  
  40.  
  41. /**
  42. * watch the page
  43. */
  44. function watch_page() {
  45.  
  46. // wathe the page, so that the rendering is updated when new contents come in as the user edits or navigates
  47. const observer = new MutationObserver(function (mutationlist) {
  48. for (const { addedNodes } of mutationlist) {
  49. for (const node of addedNodes) {
  50. if (!node.tagName) continue; // not an element
  51.  
  52. // watch ordinary node
  53. if (node.classList.contains('innerContentContainer')) {
  54. handle_node(node);
  55. }
  56.  
  57. // watch the title when it becomes empty
  58. if (node.classList.contains('contentEditablePlaceholder')) {
  59. handle_untitled(node);
  60. }
  61.  
  62. }
  63. }
  64. });
  65. observer.observe(document.body, { childList: true, subtree: true });
  66.  
  67. }
  68.  
  69.  
  70. /**
  71. * insert a container after the node with formula to contain the rendered result
  72. * @param {Node} node Dom Node
  73. */
  74. function handle_node(node) {
  75. // sometimes there is a dummy node without parent. don't know why, but we need to check and exclude it first
  76. const parent = node.parentElement;
  77. if (!parent) {
  78. return;
  79. }
  80.  
  81. // remove the class and container we previously added to avoid duplication
  82. remove_old(node);
  83.  
  84. // check if the node contains anything that should be rendered
  85. if (!has_latex(node) && !has_asciimath(node)) {
  86. return;
  87. }
  88.  
  89. // give the parent a class name so we can handle it later
  90. parent.classList.add('has-latex');
  91.  
  92. // add an element to contain the rendered latex
  93. const container = document.createElement('div');
  94. // if it's AsciiMath, it needs to be converted to LaTeX first
  95. container.innerHTML = convert_to_latex(node.innerHTML);
  96. container.className = 'rendered-latex';
  97. parent.insertAdjacentElement('afterend', container);
  98.  
  99. // replicate this class name of the parent so that the rendered block can preserve WF's original style
  100. container.classList.add(parent.classList[1]);
  101.  
  102. // render it
  103. const options = {
  104. delimiters: [
  105. { left: '$$', right: '$$', display: true },
  106. { left: '$', right: '$', display: false }
  107. ]
  108. };
  109. renderMathInElement(container, options);
  110.  
  111. // when the element is clicked, make the focus in the corresponding node so that the user can begin typing
  112. container.addEventListener('click', () => {
  113. parent.focus();
  114. });
  115.  
  116. }
  117.  
  118.  
  119. /**
  120. * check if the node contains LaTeX that should be rendered
  121. * @param {Node} node Dom Node
  122. * @returns {boolean}
  123. */
  124. function has_latex(node) {
  125. // use $ or $$ as delimiters
  126. const text = node.textContent;
  127. const regex = /\$(\$)?(.+?)\$(\$)?/s;
  128. const match = text.match(regex);
  129. if (match !== null) {
  130. return true;
  131. }
  132.  
  133. return false;
  134. }
  135.  
  136.  
  137. /**
  138. * check if the node contains AsciiMath that should be rendered
  139. * @param {Node} node Dom Node
  140. * @returns {boolean}
  141. */
  142. function has_asciimath(node) {
  143. // use ` as delimiters
  144. const text = node.textContent;
  145. const regex = /`(.+?)`/s;
  146. const match = text.match(regex);
  147. if (match !== null) {
  148. return true;
  149. }
  150.  
  151. return false;
  152. }
  153.  
  154.  
  155. /**
  156. * convert a string, changing the AsciiMath parts into LaTeX, keeping the rest unchanged
  157. * @param {string} str a string containing AsciiMath
  158. * @returns {string} a string containing converted LaTeX
  159. */
  160. function convert_to_latex(str) {
  161. // AsciiMath uses ` as delimiters
  162. const regex = /`(.+?)`/g;
  163. const parser = new AsciiMathParser();
  164. const result = str.replaceAll(regex, function (match, p1) {
  165. // convert to LaTeX with $ as delimiters
  166. return '$' + parser.parse(p1) + '$';
  167. });
  168. return result;
  169. }
  170.  
  171.  
  172. /**
  173. * hide the raw content with LaTeX. only shows it when it has focus
  174. */
  175. function hide_raw() {
  176. GM.addStyle('.name .has-latex .innerContentContainer { display:none } ');
  177. GM.addStyle('.name .has-latex.content { height: 0; min-height: 0 } ');
  178.  
  179. GM.addStyle('.name--focused .has-latex .innerContentContainer { display:inline} ');
  180. GM.addStyle('.name--focused .has-latex.content { height: auto} ');
  181.  
  182. // add a background to make the raw part look clearer
  183. GM.addStyle('.name--focused .has-latex { background: #eee } ');
  184.  
  185. // preserve line breaks in notes
  186. GM.addStyle('.notes .rendered-latex { white-space: pre-wrap } ');
  187.  
  188. // make the rendered div take up full row width
  189. GM.addStyle('.name .rendered-latex { width: 100% } ');
  190.  
  191. }
  192.  
  193.  
  194. /**
  195. * WF's default is to show the first line of a note when it's not in focus. This function hides the note completely if it contains formula and is not in focus
  196. */
  197. function hide_note_raw() {
  198. const css = `
  199. /* WF keeps overriding our class name, so we have to select the element in this roundabout way */
  200. /* add a background to the raw note in focus */
  201. .notes .active:not(div + .notes .rendered-latex) {
  202. background: #eee
  203. }
  204. .notes > div.content.active:only-child {
  205. background: transparent
  206. }
  207.  
  208. /* hide the raw note not in focus */
  209. .notes .content:not(.active):not(div + .notes .rendered-latex) {
  210. height: 0;
  211. min-height: 0;
  212. overflow: hidden;
  213. }
  214. div.noted.project > div.notes > div.content:only-child {
  215. height: revert;
  216. min-height: revert;
  217. }
  218.  
  219. /* make the rendered part gray */
  220. .notes .rendered-latex {
  221. color: #868c90;
  222. }
  223. `
  224.  
  225. GM.addStyle(css);
  226. }
  227.  
  228.  
  229. /**
  230. * load KaTex css
  231. */
  232. function load_css() {
  233. let css = GM_getResourceText("KATEX_CSS");
  234.  
  235. // the font path in the css file is relative, we need to change it to absolute
  236. css = css.replace(
  237. /fonts\//g,
  238. 'https://cdn.jsdelivr.net/npm/katex@0.15.2/dist/fonts/'
  239. );
  240.  
  241. GM.addStyle(css);
  242. }
  243.  
  244.  
  245. /**
  246. * remove the class and container we previously added to avoid duplication
  247. * @param {Node} node Dom Node
  248. */
  249. function remove_old(node) {
  250. const parent = node.parentElement;
  251.  
  252. // if a container already exists, remove it first to avoid duplication
  253. if (parent.nextSibling && parent.nextSibling.classList.contains('rendered-latex')) {
  254. parent.nextSibling.remove();
  255.  
  256. // also remove the class name we added previously
  257. parent.classList.remove('has-latex');
  258. }
  259.  
  260. // if a node becomes empty, remove the container and class name
  261. remove_empty();
  262. }
  263.  
  264.  
  265. /**
  266. * if the node is the title of the page, it needs something special handling
  267. */
  268. function handle_untitled() {
  269. // when the title becomes empty, remove the rendered container
  270. remove_empty();
  271. }
  272.  
  273.  
  274. /**
  275. * remove all the containers and class names associated with empty nodes
  276. */
  277. function remove_empty() {
  278. const empty = document.querySelectorAll('.has-latex:empty');
  279. for (let i = empty.length - 1; i >= 0; i--) {
  280. const element = empty[i];
  281. if (element.nextSibling && element.nextSibling.classList.contains('rendered-latex')) {
  282. element.nextSibling.remove();
  283. element.classList.remove('has-latex');
  284. }
  285. }
  286. }
  287.  
  288.  
  289. })();