Markdown-WorkFlowy

Supports Markdown in WorkFlowy

  1. // ==UserScript==
  2. // @name Markdown-WorkFlowy
  3. // @namespace https://github.com/BettyJJ
  4. // @version 0.3.0
  5. // @description Supports Markdown in WorkFlowy
  6. // @author Betty
  7. // @match https://workflowy.com/*
  8. // @match https://*.workflowy.com/*
  9. // @run-at document-idle
  10. // @require https://cdnjs.cloudflare.com/ajax/libs/markdown-it/12.3.2/markdown-it.min.js
  11. // @require https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.5.0/highlight.min.js
  12. // @grant GM.addStyle
  13. // @grant GM_getResourceText
  14. // @resource TUI_CSS https://cdn.jsdelivr.net/npm/@toast-ui/editor@3.1.3/dist/toastui-editor-viewer.min.css
  15. // @resource HL_CSS https://unpkg.com/@highlightjs/cdn-assets@11.5.0/styles/github.min.css
  16. // ==/UserScript==
  17.  
  18. (function () {
  19. 'use strict';
  20.  
  21. // render WorkFlowy native formatting, such as bold, colors, links and tags, even though they are not Markdown
  22. // if you want pure Markdown, change the last word in the following line from "true" to "false"
  23. const render_workflowy_formatting = true;
  24.  
  25.  
  26. init();
  27.  
  28.  
  29. /**
  30. * initialize
  31. */
  32. function init() {
  33.  
  34. wait_for_page_load();
  35.  
  36. watch_page();
  37.  
  38. load_css();
  39.  
  40. }
  41.  
  42.  
  43. /**
  44. * wait till the page is loaded
  45. */
  46. function wait_for_page_load() {
  47.  
  48. const observer = new MutationObserver(function (mutation_list) {
  49. for (const { addedNodes } of mutation_list) {
  50. for (const node of addedNodes) {
  51. if (!node.tagName) continue; // not an element
  52.  
  53. // this element appears when the page is loaded
  54. if (node.classList.contains('pageContainer')) {
  55. show_preview_button();
  56. }
  57.  
  58. }
  59. }
  60. });
  61. observer.observe(document.body, { childList: true, subtree: true });
  62.  
  63. }
  64.  
  65.  
  66. /**
  67. * show preview button
  68. */
  69. function show_preview_button() {
  70. // show preview button
  71. const btn = document.createElement('a');
  72. btn.className = 'bmd-preview-button';
  73. btn.textContent = 'Preview MD';
  74. const active_page = document.querySelector('.page.active');
  75. active_page.insertAdjacentElement('afterend', btn);
  76.  
  77. // insert preview box
  78. const preview = document.createElement('div');
  79. preview.className = 'bmd-preview-box';
  80. active_page.insertAdjacentElement('afterend', preview);
  81.  
  82. // bind click event
  83. btn.addEventListener('click', function () {
  84. // toggle class for page container
  85. const page_container = document.querySelector('.pageContainer');
  86. page_container.classList.toggle('bmd-has-preview');
  87.  
  88. // show the preview content if the box is not hidden
  89. if (page_container.classList.contains('bmd-has-preview')) {
  90. show_preview_content();
  91. }
  92. });
  93. }
  94.  
  95.  
  96. /**
  97. * show the preview content
  98. */
  99. function show_preview_content() {
  100. const raw = get_raw_content();
  101.  
  102. const preview = document.querySelector('.bmd-preview-box');
  103.  
  104. // use tui editor's style
  105. let content = document.querySelector('.toastui-editor-contents');
  106. if (content === null) {
  107. content = document.createElement('div');
  108. content.className = 'toastui-editor-contents';
  109. preview.appendChild(content);
  110. }
  111.  
  112. const md = get_mdit();
  113. const result = md.render(raw);
  114. content.innerHTML = result;
  115. }
  116.  
  117.  
  118. /**
  119. * get raw content
  120. * @returns {string}
  121. */
  122. function get_raw_content() {
  123. const node_list = document.getElementsByClassName('innerContentContainer');
  124.  
  125. let raw = '';
  126. for (let i = 0; i < node_list.length; i++) {
  127. const node = node_list[i];
  128.  
  129. // sometimes there is repetition. don't know why, but we need to check and exclude it first
  130. const parent = node.parentElement;
  131. const style = parent.getAttribute('style');
  132. if (style === null || style === '' || style.indexOf('visibility') !== -1) {
  133. const text = get_node_text(node);
  134. raw += text + '\n';
  135. }
  136.  
  137. }
  138.  
  139. return raw;
  140. }
  141.  
  142.  
  143. /**
  144. * return the object of markdown-it
  145. * @returns {object}
  146. */
  147. function get_mdit() {
  148. const md = window.markdownit({
  149. breaks: true,
  150. highlight: function (str, lang) {
  151. if (lang && hljs.getLanguage(lang)) {
  152. try {
  153. return hljs.highlight(str, { language: lang }).value;
  154. } catch (__) { }
  155. }
  156.  
  157. return '';
  158. },
  159. html: true,
  160. linkify: true,
  161. });
  162.  
  163. return md;
  164. }
  165.  
  166.  
  167. /**
  168. * watch the page
  169. */
  170. function watch_page() {
  171.  
  172. // wathe the page, so that the rendering is updated when new contents come in as the user edits or navigates
  173. const observer = new MutationObserver(function (mutationlist) {
  174. for (const { addedNodes } of mutationlist) {
  175. for (const node of addedNodes) {
  176. if (!node.tagName) continue; // not an element
  177.  
  178. if (node.classList.contains('innerContentContainer')) {
  179. update_preview();
  180. }
  181.  
  182. }
  183. }
  184. // to monitor notes getting in and out of focus, we need to watch for attribute changes
  185. for (const { type, target } of mutationlist) {
  186. if (type === 'attributes' && target.classList.contains('content')) {
  187. update_preview();
  188. }
  189. }
  190. });
  191.  
  192. observer.observe(document.body, {
  193. attributeFilter: ['class'],
  194. childList: true, subtree: true
  195. });
  196.  
  197. }
  198.  
  199.  
  200. /**
  201. * update preview if the preview box is shown
  202. */
  203. function update_preview() {
  204. // only update the preview content if the box is not hidden
  205. const page_container = document.querySelector('.pageContainer');
  206. if (!page_container.classList.contains('bmd-has-preview')) {
  207. return;
  208. }
  209.  
  210. const content = document.querySelector('.toastui-editor-contents');
  211. if (!content) {
  212. return;
  213. }
  214.  
  215. // update the preview
  216. const raw = get_raw_content();
  217. const md = get_mdit();
  218. const result = md.render(raw);
  219. content.innerHTML = result;
  220. }
  221.  
  222.  
  223. /**
  224. * get the text of a node
  225. * @param {Node} node Dom Node
  226. * @returns {string}
  227. */
  228. function get_node_text(node) {
  229. let text = '';
  230.  
  231. // show the first line of a note by default and the whole note if it is in focus or the root, just like native WF
  232. const is_note = node.parentElement.parentElement.classList.contains('notes');
  233. const is_active = node.parentElement.classList.contains('active');
  234. const is_root = node.parentElement.parentElement.parentElement.classList.contains('root');
  235. const is_inactive_note = (is_note && !is_active && !is_root);
  236.  
  237. const div = document.createElement('div');
  238. if (is_inactive_note) {
  239. div.innerHTML = node.innerHTML.split('\n')[0];
  240. } else {
  241. div.innerHTML = node.innerHTML;
  242. }
  243.  
  244. if (!render_workflowy_formatting) {
  245. text = div.textContent;
  246. }
  247. // handle WF native formatting
  248. else {
  249. while (div.firstChild) {
  250. const child = div.firstChild;
  251. // WF has autolinking. we need to remove the links to avoid double conversion
  252. if (child.href && remove_trailing_slash(child.href) === remove_trailing_slash(child.textContent)) {
  253. text += child.textContent;
  254. }
  255. // Markdown headings starting with ## is treated as tags in WF. we need to fix it
  256. else if (child.classList && child.classList.contains('contentTag')) {
  257. const tag = child.getAttribute('data-val');
  258. const only_contains_sharp = new RegExp('^#+$').test(tag);
  259. const is_line_start = ((text === '') || (text[text.length - 1] === '\n'));
  260. if (only_contains_sharp && is_line_start) {
  261. text += tag;
  262. } else {
  263. text += child.outerHTML;
  264. }
  265. }
  266. // in other cases, use HTML if possible
  267. else {
  268. if (child.outerHTML) {
  269. text += child.outerHTML;
  270. } else {
  271. text += child.textContent;
  272. }
  273. }
  274. div.removeChild(child);
  275. }
  276.  
  277. // WF converts < and >. we need them for MD to work
  278. text = text.replaceAll('&lt;', '<');
  279. text = text.replaceAll('&gt;', '>');
  280. }
  281.  
  282. return text;
  283. }
  284.  
  285.  
  286. /**
  287. * remove the trailing slash in a url
  288. * @param {string} url
  289. * @returns {string}
  290. */
  291. function remove_trailing_slash(url) {
  292. return (url[url.length - 1] == "/") ? url.substr(0, url.length - 1) : url;
  293. }
  294.  
  295.  
  296. /**
  297. * load css
  298. */
  299. function load_css() {
  300. const tui_css = GM_getResourceText('TUI_CSS');
  301. GM.addStyle(tui_css);
  302.  
  303. const hl_css = GM_getResourceText('HL_CSS');
  304. GM.addStyle(hl_css);
  305.  
  306. // style for preview content, mainly fixing interfering styles
  307. GM.addStyle(`
  308. .toastui-editor-contents th, .toastui-editor-contents tr, .toastui-editor-contents td {
  309. vertical-align: middle;
  310. }
  311. .toastui-editor-contents {
  312. font-size: 15px;
  313. }
  314.  
  315. /* support dark mode */
  316. .toastui-editor-contents h1, .toastui-editor-contents h2, .toastui-editor-contents h3, .toastui-editor-contents h4, .toastui-editor-contents h5, .toastui-editor-contents h6
  317. , .toastui-editor-contents p
  318. , .toastui-editor-contents dir, .toastui-editor-contents menu, .toastui-editor-contents ol, .toastui-editor-contents ul
  319. , .toastui-editor-contents table
  320. {
  321. color: revert;
  322. }
  323. .toastui-editor-contents table td, .toastui-editor-contents table th {
  324. border: 1px solid #dadada;
  325. }
  326. .toastui-editor-contents pre code {
  327. color: #2a3135;
  328. }
  329.  
  330. .bmd-preview-box .contentTag {
  331. color: #868c90;
  332. cursor: pointer;
  333. }
  334. .bmd-preview-box .contentTag .contentTagText {
  335. text-decoration: underline;
  336. }
  337.  
  338. `);
  339.  
  340. // style for the preview box
  341. GM.addStyle(`
  342. .bmd-preview-button {
  343. background: white;
  344. border: solid 1px;
  345. color: #2a3135;
  346. padding: 6px;
  347. position: absolute;
  348. right: 24px;
  349. top: 50px;
  350. }
  351. .bmd-preview-button:hover {
  352. background: lightgray;
  353. text-decoration: none;
  354. }
  355.  
  356. .bmd-preview-box {
  357. display: none;
  358. }
  359. .bmd-has-preview .bmd-preview-box {
  360. display: block;
  361. }
  362.  
  363. .bmd-has-preview {
  364. display: flex;
  365. }
  366. .bmd-has-preview .page.active {
  367. flex-basis: 50%;
  368. flex-grow: 1;
  369. padding-left: 24px;
  370. padding-right: 24px;
  371. word-break: break-word;
  372. }
  373. .bmd-preview-box {
  374. border: solid 1px lightgray;
  375. flex-basis: 50%;
  376. flex-grow: 1;
  377. margin-top: 72px;
  378. padding: 24px;
  379. user-select: text;
  380. }
  381.  
  382. `);
  383.  
  384. }
  385.  
  386.  
  387. })();