Canvas Markdown

Adds a markdown editor to Canvas

  1. // ==UserScript==
  2. // @name Canvas Markdown
  3. // @namespace https://theusaf.org
  4. // @version 3.0.2
  5. // @description Adds a markdown editor to Canvas
  6. // @author theusaf
  7. // @supportURL https://github.com/theusaf/canvas-markdown/issues
  8. // @copyright (c) 2023-2024 theusaf
  9. // @homepage https://github.com/theusaf/canvas-markdown
  10. // @license MIT
  11. // @match https://*/*
  12. // @grant none
  13. // ==/UserScript==
  14. let highlight, languages;
  15. try {
  16. if (new URL(document.querySelector("#global_nav_help_link")
  17. ?.href ?? "")?.hostname === "help.instructure.com") {
  18. console.log("[Canvas Markdown] Detected Canvas page, loading...");
  19. (async () => {
  20. console.log("[Canvas Markdown] Importing dependencies...");
  21. await import("https://cdn.jsdelivr.net/gh/theusaf/canvas-markdown@5216c569489b9aa2caa6aee49ef8aadabb1f1794/lib/codemirror/codemirror.js");
  22. await import("https://cdn.jsdelivr.net/gh/theusaf/canvas-markdown@5216c569489b9aa2caa6aee49ef8aadabb1f1794/lib/codemirror/mode/markdown/markdown.js");
  23. highlight = (await import("https://cdn.jsdelivr.net/gh/theusaf/canvas-markdown@5216c569489b9aa2caa6aee49ef8aadabb1f1794/lib/highlight/es/core.min.js")).default;
  24. languages = (await import("https://cdn.jsdelivr.net/gh/theusaf/canvas-markdown@5216c569489b9aa2caa6aee49ef8aadabb1f1794/lib/highlight/languages.js")).default;
  25. const s = document.createElement("script");
  26. s.src =
  27. "https://cdn.jsdelivr.net/npm/showdown@2.1.0/dist/showdown.min.js";
  28. document.head.append(s);
  29. const showdownKatex = document.createElement("script");
  30. showdownKatex.src =
  31. "https://cdn.jsdelivr.net/npm/showdown-katex@0.8.0/dist/showdown-katex.min.js";
  32. document.head.append(showdownKatex);
  33. const codemirrorCSS = document.createElement("link");
  34. codemirrorCSS.rel = "stylesheet";
  35. codemirrorCSS.href =
  36. "https://cdn.jsdelivr.net/gh/theusaf/canvas-markdown@5216c569489b9aa2caa6aee49ef8aadabb1f1794/lib/codemirror/codemirror.css";
  37. const highlightCSS = document.createElement("link");
  38. highlightCSS.rel = "stylesheet";
  39. highlightCSS.href =
  40. "https://cdn.jsdelivr.net/gh/theusaf/canvas-markdown@5216c569489b9aa2caa6aee49ef8aadabb1f1794/lib/highlight/styles/github-dark.min.css";
  41. document.head.append(highlightCSS);
  42. document.head.append(codemirrorCSS);
  43. console.log("[Canvas Markdown] Setting up...");
  44. setupWatcher();
  45. console.log("[Canvas Markdown] Done.");
  46. })();
  47. }
  48. else {
  49. console.log("[Canvas Markdown] Not a Canvas page, skipping...");
  50. }
  51. }
  52. catch (e) {
  53. /* ignore */
  54. }
  55. function getEditorElements() {
  56. return [
  57. ...document.querySelectorAll(".ic-RichContentEditor:not([md-id=canvas-container])"),
  58. ];
  59. }
  60. function setupWatcher() {
  61. setInterval(() => {
  62. const potentialEditorElements = getEditorElements();
  63. if (potentialEditorElements.length) {
  64. for (const editorElement of potentialEditorElements) {
  65. const markdownEditor = new MarkdownEditor(editorElement);
  66. markdownEditor.setup();
  67. }
  68. }
  69. }, 1e3);
  70. }
  71. var MarkdownEditorMode;
  72. (function (MarkdownEditorMode) {
  73. MarkdownEditorMode[MarkdownEditorMode["RAW"] = 0] = "RAW";
  74. MarkdownEditorMode[MarkdownEditorMode["PRETTY"] = 1] = "PRETTY";
  75. })(MarkdownEditorMode || (MarkdownEditorMode = {}));
  76. // https://developer.mozilla.org/en-US/docs/Web/API/btoa#unicode_strings
  77. function toBinary(str) {
  78. const codeUnits = Uint16Array.from({ length: str.length }, (_, index) => str.charCodeAt(index)), charCodes = new Uint8Array(codeUnits.buffer);
  79. let result = "";
  80. charCodes.forEach((char) => {
  81. result += String.fromCharCode(char);
  82. });
  83. return result;
  84. }
  85. function fromBinary(binary) {
  86. const bytes = Uint8Array.from({ length: binary.length }, (element, index) => binary.charCodeAt(index)), charCodes = new Uint16Array(bytes.buffer);
  87. let result = "";
  88. charCodes.forEach((char) => {
  89. result += String.fromCharCode(char);
  90. });
  91. return result;
  92. }
  93. // From https://github.com/halbgut/showdown-footnotes
  94. function showdownFootnotes(options) {
  95. const { prefix } = options ?? { prefix: "footnote" };
  96. return [
  97. // Bottom footnotes
  98. {
  99. type: "lang",
  100. filter: (text, converter) => {
  101. const regex = /^\[\^([\w]+)\]:[^\S\r\n]*(.*(\n[^\S\r\n]{2,}.*)*)$/gm, regex2 = new RegExp(`\n${regex.source}`, "gm"), footnotes = text.match(regex), footnotesOutput = [];
  102. if (footnotes) {
  103. for (const footnote of footnotes) {
  104. const name = footnote.match(/^\[\^([\w]+)\]/)[1], footnoteContent = footnote.replace(/^\[\^([\w]+)\]:[^\S\r\n]*/, "");
  105. let content = converter.makeHtml(footnoteContent.replace(/[^\S\r\n]{2}/gm, ""));
  106. if (content.startsWith("<p>") &&
  107. content.endsWith("</p>") &&
  108. !footnoteContent.startsWith("<p>")) {
  109. content = content.slice(3, -4);
  110. }
  111. footnotesOutput.push(`<li class="footnote" value="${name}" id="${prefix}-ref-${name}">${content}</li>`);
  112. }
  113. }
  114. text = text.replace(regex2, "").trim();
  115. if (footnotesOutput.length) {
  116. text += `<hr id="showdown-footnote-seperator"><ol class="footnotes">${footnotesOutput.join("\n")}</ol>`;
  117. }
  118. return text;
  119. },
  120. },
  121. // Inline footnotes
  122. {
  123. type: "lang",
  124. filter: (text) => text.replace(/\[\^([\w]+)\]/gm, (str, name) => `<a href="#${prefix}-ref-${name}"><sup>[${name}]</sup></a>`),
  125. },
  126. ];
  127. }
  128. function showdownSpecialBlocks() {
  129. function createImage(icon) {
  130. return `<span style="font-size: 1.25rem; width: 1.25rem">${icon}</span>`;
  131. }
  132. function replacer(prefix, svg, type) {
  133. return `${prefix}<p class="cm-alert-${type}" style="display: flex; align-items: center; color: ${specialBlockColors[type]}">${createImage(svg)}<span style="margin-left: 0.5rem;">${type[0].toUpperCase() + type.slice(1)}</span></p>`;
  134. }
  135. const specialBlockColors = {
  136. note: "#1f6fec",
  137. tip: "#228636",
  138. caution: "#da3333",
  139. warning: "#9e6a00",
  140. important: "#8957e0",
  141. };
  142. return [
  143. {
  144. type: "lang",
  145. regex: /(>\s*)\[!NOTE]/,
  146. replace: (_, prefix) => replacer(prefix, "🛈", "note"),
  147. },
  148. {
  149. type: "lang",
  150. regex: /(>\s*)\[!TIP]/,
  151. replace: (_, prefix) => replacer(prefix, "💡", "tip"),
  152. },
  153. {
  154. type: "lang",
  155. regex: /(>\s*)\[!CAUTION]/,
  156. replace: (_, prefix) => replacer(prefix, "🛑", "caution"),
  157. },
  158. {
  159. type: "lang",
  160. regex: /(>\s*)\[!WARNING]/,
  161. replace: (_, prefix) => replacer(prefix, "⚠", "warning"),
  162. },
  163. {
  164. type: "lang",
  165. regex: /(>\s*)\[!IMPORTANT]/,
  166. replace: (_, prefix) => replacer(prefix, "🗪", "important"),
  167. },
  168. {
  169. type: "output",
  170. regex: /<blockquote>\s*<p class="cm-alert-(\w+)"/gm,
  171. replace(text, type) {
  172. const color = specialBlockColors[type];
  173. if (!color)
  174. return text;
  175. return `<blockquote style="border-left-color: ${color};"><p class="cm-alert-${type}"`;
  176. },
  177. },
  178. ];
  179. }
  180. class MarkdownEditor {
  181. editorContainer;
  182. canvasTextArea;
  183. canvasResizeHandle;
  184. canvasSwitchEditorButton;
  185. canvasFullScreenButton;
  186. markdownTextContainer;
  187. markdownPrettyContainer;
  188. markdownTextArea;
  189. markdownEditor;
  190. markdownSettingsButton;
  191. markdownSwitchButton;
  192. markdownSwitchTypeButton;
  193. markdownSettingsExistingContainer;
  194. encodedOutput;
  195. showdownConverter;
  196. active = false;
  197. mode = MarkdownEditorMode.PRETTY;
  198. activating = false;
  199. uniqueId = Date.now().toString();
  200. constructor(editor) {
  201. this.editorContainer = editor;
  202. }
  203. setup() {
  204. this.editorContainer.setAttribute("md-id", "canvas-container");
  205. if (this.isReady()) {
  206. this.canvasTextArea = this.getCanvasTextArea();
  207. this.canvasResizeHandle = this.getCanvasResizeHandle();
  208. this.canvasSwitchEditorButton = this.getCanvasSwitchEditorButton();
  209. this.canvasFullScreenButton = this.getCanvasFullScreenButton();
  210. this.injectMarkdownEditor();
  211. this.setupShowdown();
  212. this.injectMarkdownSettingsButton();
  213. this.injectMarkdownUI();
  214. this.applyEventListeners();
  215. }
  216. else {
  217. setTimeout(() => this.setup(), 1e3);
  218. }
  219. }
  220. isReady() {
  221. return !!(this.getCanvasTextArea() &&
  222. this.getCanvasResizeHandle() &&
  223. this.getCanvasSwitchEditorButton() &&
  224. this.getCanvasFullScreenButton());
  225. }
  226. getCanvasFullScreenButton() {
  227. return this.editorContainer.querySelector("[data-btn-id=rce-fullscreen-btn]");
  228. }
  229. setupShowdown() {
  230. showdown.setFlavor("github");
  231. this.showdownConverter = new showdown.Converter({
  232. ghMentions: false,
  233. parseImgDimensions: true,
  234. underline: true,
  235. extensions: [
  236. window.showdownKatex({}),
  237. showdownFootnotes({
  238. prefix: this.uniqueId,
  239. }),
  240. showdownSpecialBlocks(),
  241. ],
  242. });
  243. }
  244. getCanvasResizeHandle() {
  245. return this.editorContainer.querySelector("[data-btn-id=rce-resize-handle]");
  246. }
  247. getCanvasTextArea() {
  248. return this.editorContainer.querySelector("textarea[data-rich_text=true]");
  249. }
  250. getCanvasSwitchEditorButton() {
  251. return this.editorContainer.querySelector("[data-btn-id=rce-edit-btn]");
  252. }
  253. isCanvasInTextMode() {
  254. return /rich text/i.test(this.canvasSwitchEditorButton.title);
  255. }
  256. getCanvasSwitchTypeButton() {
  257. return this.editorContainer.querySelector("[data-btn-id=rce-editormessage-btn]");
  258. }
  259. isCanvasInPlainTextMode() {
  260. return /pretty html/i.test(this.getCanvasSwitchTypeButton().textContent);
  261. }
  262. insertAfter(newNode, referenceNode) {
  263. referenceNode.parentNode.insertBefore(newNode, referenceNode.nextSibling);
  264. }
  265. injectMarkdownEditor() {
  266. const editorContent = document.createElement("template");
  267. editorContent.innerHTML = `
  268. <div md-id="markdown-editor-text-container" style="display: none;">
  269. <textarea md-id="markdown-editor" style="height: 400px; resize: none;"></textarea>
  270. </div>
  271. <div md-id="markdown-editor-pretty-container">
  272. <div class="RceHtmlEditor">
  273. <div>
  274. <label style="display: block">
  275. <span></span>
  276. <div class="react-codemirror2" md-id="markdown-editor-codemirror-container">
  277. <!-- Insert CodeMirror editor here -->
  278. </div>
  279. </label>
  280. </div>
  281. </div>
  282. </div>
  283. `;
  284. this.editorContainer
  285. .querySelector(".rce-wrapper")
  286. .prepend(editorContent.content.cloneNode(true));
  287. this.markdownTextContainer = this.editorContainer.querySelector("[md-id=markdown-editor-text-container]");
  288. this.markdownTextArea = this.editorContainer.querySelector("[md-id=markdown-editor]");
  289. this.markdownPrettyContainer = this.editorContainer.querySelector("[md-id=markdown-editor-pretty-container]");
  290. this.markdownEditor = CodeMirror(this.markdownPrettyContainer.querySelector("[md-id=markdown-editor-codemirror-container]"), {
  291. mode: "markdown",
  292. lineNumbers: true,
  293. lineWrapping: true,
  294. });
  295. const codeMirrorEditor = this.markdownEditor.getWrapperElement();
  296. codeMirrorEditor.style.height = "400px";
  297. codeMirrorEditor.setAttribute("md-id", "markdown-editor-codemirror");
  298. // Hide the markdown editor. By doing it here, it also allows CodeMirror to
  299. // properly render when the editor is shown.
  300. this.markdownPrettyContainer.style.display = "none";
  301. }
  302. displaySettings() {
  303. const settingsUI = document.createElement("template");
  304. settingsUI.innerHTML = `
  305. <div md-id="settings-container">
  306. <style>
  307. [md-id=settings-container] {
  308. position: fixed;
  309. top: 0;
  310. left: 0;
  311. width: 100%;
  312. height: 100%;
  313. background-color: rgb(0, 0, 0, 0.5);
  314. z-index: 999;
  315. display: flex;
  316. }
  317. [md-id=settings-container] > div {
  318. width: 90%;
  319. height: 90%;
  320. margin: auto;
  321. overflow-y: auto;
  322. background-color: white;
  323. padding: 1rem;
  324. position: relative;
  325. border-radius: 0.5rem;
  326. }
  327. [md-id=settings-container] h2 {
  328. margin-top: 1rem;
  329. }
  330. [md-id=close-button] {
  331. position: fixed;
  332. top: 2%;
  333. right: 4%;
  334. padding: 0.5rem;
  335. cursor: pointer;
  336. width: 1rem;
  337. height: 1rem;
  338. color: black;
  339. font-size: 1.5rem;
  340. text-align: center;
  341. margin: 0.5rem;
  342. text-shadow: black 0 0 0.2rem;
  343. }
  344. [md-id=settings-form-container]
  345. [md-id=settings-existing-container] {
  346. display: flex;
  347. flex-direction: column;
  348. margin-top: 1rem;
  349. }
  350. [md-id=settings-form-label-container] {
  351. display: flex;
  352. flex-direction: row;
  353. margin-bottom: 0.5rem;
  354. }
  355. [md-id=settings-form-label-container] > * {
  356. font-weight: bold;
  357. flex: 1;
  358. padding: 0.5rem;
  359. font-size: 1.2rem;
  360. }
  361. [md-id=settings-existing-input-container] {
  362. margin-top: 1rem;
  363. }
  364. [md-id=settings-existing-container] {
  365. margin-top: 1rem;
  366. border-top: 0.15rem solid #ccc;
  367. }
  368. [md-id=settings-form-input-container],
  369. [md-id=settings-existing-input-container] {
  370. display: flex;
  371. flex-direction: row;
  372. }
  373. [md-id=settings-form-input-container] > *,
  374. [md-id=settings-existing-input-container] > * {
  375. flex: 1;
  376. padding: 0.5rem;
  377. display: flex;
  378. }
  379. [md-id=settings-form-input-container] > * > input,
  380. [md-id=settings-form-input-container] > * > textarea,
  381. [md-id=settings-existing-input-container] > * > input,
  382. [md-id=settings-existing-input-container] > * > textarea {
  383. flex: 1;
  384. }
  385. [md-id=settings-form-label-container] > :nth-child(2n + 1),
  386. [md-id=settings-form-input-container] > :nth-child(2n + 1),
  387. [md-id=settings-existing-input-container] > :nth-child(2n + 1) {
  388. background-color: #eee;
  389. }
  390. [md-id="settings-download-button-container"] > * {
  391. padding: 0.5rem;
  392. background-color: #eee;
  393. border: 0.15rem solid #ccc;
  394. border-radius: 0.5rem;
  395. margin: 0.5rem;
  396. cursor: pointer;
  397. }
  398. [md-id="settings-form-save-button"] {
  399. height: 2rem;
  400. }
  401. [md-id="settings-form-save-tooltip"] {
  402. position: absolute;
  403. top: -4.25rem;
  404. left: -25%;
  405. width: 4rem;
  406. height: 4rem;
  407. pointer-events: none;
  408. background-color: black;
  409. text-align: center;
  410. border-radius: 0.5rem;
  411. align-items: center;
  412. justify-content: center;
  413. display: flex;
  414. color: white;
  415. opacity: 0;
  416. transition: opacity 0.2s ease-in-out;
  417. }
  418. [md-id="settings-remove-backup-label"] {
  419. display: flex;
  420. align-items: center;
  421. }
  422. [md-id="settings-remove-backup-label"] input {
  423. display: none;
  424. }
  425. [md-id="settings-remove-backup-label"] span {
  426. display: inline-block;
  427. width: 1rem;
  428. height: 1rem;
  429. border-radius: 0.25rem;
  430. border: 0.15rem solid #ccc;
  431. margin-right: 0.5rem;
  432. cursor: pointer;
  433. }
  434. [md-id="settings-remove-backup-label"] input:checked + span {
  435. background-color: blue;
  436. }
  437. </style>
  438. <div>
  439. <span md-id="close-button">X</span>
  440. <h2>Canvas Markdown Settings</h2>
  441. <h3>Custom Styles</h3>
  442. <p>
  443. You can use these settings to customize the default styles of HTML elements in the output.
  444. In the form below, input a tag or CSS selector to target in the first section. In the second section,
  445. input the CSS properties you want to apply to the element as you would in a style attribute.
  446. </p>
  447. <div md-id="settings-form-container">
  448. <!-- Insert form here -->
  449. <div md-id="settings-form-label-container">
  450. <label for="cm-settings-selector">Selector</label>
  451. <label for="cm-settings-style">Style</label>
  452. <span>Style Preview</span>
  453. </div>
  454. <div md-id="settings-form-input-container">
  455. <!-- Insert form inputs here -->
  456. </div>
  457. </div>
  458. <div md-id="settings-existing-container" style="">
  459. <!-- Insert existing settings here -->
  460. </div>
  461. <h3>Remove Markdown Backup</h3>
  462. <div>
  463. <p>
  464. By default, Canvas Markdown will save a backup of the raw markdown code in an invisible element at the end of the HTML output.
  465. This is to allow you to edit the markdown code later. If you do not want this backup, you can disable it here. This may be done
  466. to reduce the size of the HTML output and stay within
  467. <a href="https://community.canvaslms.com/t5/Canvas-Resource-Documents/Canvas-Character-Limits/ta-p/529365">character limits</a>.
  468. </p>
  469. <p>
  470. When this option is enabled, the original markdown source will be lost after submission or page refresh. Attempting to edit
  471. the markdown code later will result in a blank editor.
  472. </p>
  473. </p>
  474. <label for="cm-settings-remove-backup" md-id="settings-remove-backup-label">
  475. <input type="checkbox" id="cm-settings-remove-backup" />
  476. <span></span>
  477. Remove Markdown Backup
  478. </label>
  479. </div>
  480. <h3>Import/Export Settings</h3>
  481. <div md-id="settings-download-container">
  482. <!-- Insert download/load settings here -->
  483. <div md-id="settings-download-button-container">
  484. <label md-id="settings-download-button">Download Settings</label>
  485. <label for="cm-settings-upload-input" md-id="settings-upload-button">
  486. Upload Settings
  487. </label>
  488. <input
  489. type="file"
  490. accept=".json"
  491. id="cm-settings-upload-input"
  492. md-id="settings-upload-input"
  493. style="display: none;" />
  494. </div>
  495. </div>
  496. </div>
  497. </div>
  498. `;
  499. document.body.append(settingsUI.content.cloneNode(true));
  500. const settingsContainer = document.querySelector("[md-id=settings-container]"), closeButton = document.querySelector("[md-id=close-button]"), downloadButton = document.querySelector("[md-id=settings-download-button]"), uploadButton = document.querySelector("[md-id=settings-upload-button]"), removeBackupCheckbox = document.querySelector("#cm-settings-remove-backup");
  501. closeButton.addEventListener("click", () => {
  502. settingsContainer.remove();
  503. });
  504. downloadButton.addEventListener("click", () => {
  505. const settings = this.loadSettings();
  506. const blob = new Blob([JSON.stringify(settings)], {
  507. type: "application/json",
  508. });
  509. const url = URL.createObjectURL(blob);
  510. const a = document.createElement("a");
  511. a.href = url;
  512. a.download = "canvas-markdown-settings.json";
  513. a.click();
  514. URL.revokeObjectURL(url);
  515. });
  516. uploadButton.addEventListener("click", () => {
  517. const input = document.querySelector("[md-id=settings-upload-input]");
  518. input.onchange = () => {
  519. const file = input.files[0];
  520. if (file.type !== "application/json") {
  521. alert("Invalid file type");
  522. return;
  523. }
  524. const reader = new FileReader();
  525. reader.onload = () => {
  526. try {
  527. const settings = JSON.parse(reader.result);
  528. this.saveSettings(settings);
  529. settingsContainer.remove();
  530. this.displaySettings();
  531. }
  532. catch (e) {
  533. alert("Invalid file");
  534. }
  535. };
  536. reader.readAsText(file);
  537. };
  538. });
  539. removeBackupCheckbox.addEventListener("change", () => {
  540. this.saveSettings({
  541. removeMarkdownBackup: removeBackupCheckbox.checked,
  542. });
  543. });
  544. this.markdownSettingsExistingContainer = document.querySelector("[md-id=settings-existing-container]");
  545. const settings = this.loadSettings();
  546. removeBackupCheckbox.checked = settings.removeMarkdownBackup;
  547. for (const setting of settings.customStyles) {
  548. const container = this.createExistingSettingsContainer();
  549. this.markdownSettingsExistingContainer.append(container);
  550. this.addSettingsForm(container, setting, true);
  551. }
  552. this.addSettingsForm(document.querySelector("[md-id=settings-form-input-container]"), null, false);
  553. }
  554. addSettingsForm(formContainer, setting = null, isExisting = true) {
  555. const formInputTemplate = document.createElement("template");
  556. formInputTemplate.innerHTML = `
  557. <span>
  558. <input
  559. type="text" id="cm-settings-selector"
  560. md-id="settings-form-selector"
  561. placeholder="e.g. h1, .header, #header" />
  562. </span>
  563. <span>
  564. <textarea
  565. id="cm-settings-style"
  566. md-id="settings-form-style"
  567. placeholder="e.g. color: red; font-weight: bold;"></textarea>
  568. </span>
  569. <span style="justify-content: space-between; display: flex;">
  570. <div>
  571. <span md-id="settings-form-style-preview">Hello World</span>
  572. </div>
  573. <div>
  574. <span style="position: relative">
  575. <span md-id="settings-form-save-tooltip"></span>
  576. <button md-id="settings-form-save-button">Save</button>
  577. </span>
  578. <button md-id="settings-form-delete-button" style="margin-left: 0.5rem">Delete</button>
  579. </div>
  580. </span>
  581. `;
  582. formContainer.append(formInputTemplate.content.cloneNode(true));
  583. const saveButton = formContainer.querySelector("[md-id=settings-form-save-button]"), deleteButton = formContainer.querySelector("[md-id=settings-form-delete-button]"), stylePreview = formContainer.querySelector("[md-id=settings-form-style-preview]"), saveTooltip = formContainer.querySelector("[md-id=settings-form-save-tooltip]"), selectorInput = formContainer.querySelector("[md-id=settings-form-selector]"), styleInput = formContainer.querySelector("[md-id=settings-form-style]");
  584. if (!isExisting) {
  585. deleteButton.style.display = "none";
  586. }
  587. if (setting) {
  588. selectorInput.value = setting.target;
  589. styleInput.value = setting.style;
  590. stylePreview.style.cssText = setting.style;
  591. }
  592. // Add event listeners
  593. deleteButton.addEventListener("click", () => {
  594. formContainer.remove();
  595. this.saveSettingsFromForm();
  596. });
  597. saveButton.addEventListener("click", () => {
  598. const isValid = this.isSettingsValid({
  599. target: selectorInput.value,
  600. style: styleInput.value,
  601. });
  602. if (isValid !== true) {
  603. saveTooltip.style.opacity = "1";
  604. saveTooltip.textContent = isValid;
  605. saveTooltip.style.backgroundColor = "red";
  606. setTimeout(() => {
  607. saveTooltip.style.opacity = "0";
  608. }, 500);
  609. }
  610. else {
  611. if (!isExisting) {
  612. const container = this.createExistingSettingsContainer();
  613. this.markdownSettingsExistingContainer.append(container);
  614. this.addSettingsForm(container, {
  615. target: selectorInput.value,
  616. style: styleInput.value,
  617. }, true);
  618. selectorInput.value = "";
  619. styleInput.value = "";
  620. stylePreview.style.cssText = "";
  621. }
  622. this.saveSettingsFromForm();
  623. saveTooltip.style.opacity = "1";
  624. saveTooltip.textContent = "Saved!";
  625. saveTooltip.style.backgroundColor = "green";
  626. setTimeout(() => {
  627. saveTooltip.style.opacity = "0";
  628. }, 500);
  629. }
  630. });
  631. styleInput.addEventListener("input", () => {
  632. stylePreview.style.cssText = styleInput.value;
  633. });
  634. }
  635. createExistingSettingsContainer() {
  636. const existingSettingsContainer = document.createElement("div");
  637. existingSettingsContainer.setAttribute("md-id", "settings-existing-input-container");
  638. return existingSettingsContainer;
  639. }
  640. isSettingsValid(settings) {
  641. const { target, style } = settings;
  642. if (!target.trim() || !style.trim())
  643. return "Empty inputs";
  644. try {
  645. document.querySelector(target);
  646. }
  647. catch (e) {
  648. return "Invalid selector";
  649. }
  650. return true;
  651. }
  652. saveSettingsFromForm() {
  653. const settings = this.getSettingsFromForm();
  654. this.saveSettings({
  655. customStyles: settings,
  656. });
  657. }
  658. getSettingsFromForm() {
  659. const formContainers = [
  660. ...this.markdownSettingsExistingContainer.querySelectorAll("[md-id=settings-existing-input-container]"),
  661. ], settings = [];
  662. for (const formContainer of formContainers) {
  663. const selectorInput = formContainer.querySelector("[md-id=settings-form-selector]"), styleInput = formContainer.querySelector("[md-id=settings-form-style]"), setting = {
  664. target: selectorInput.value,
  665. style: styleInput.value,
  666. };
  667. if (this.isSettingsValid(setting) === true)
  668. settings.push(setting);
  669. }
  670. return settings;
  671. }
  672. loadSettings() {
  673. const defaultSettings = {
  674. customStyles: [],
  675. removeMarkdownBackup: false,
  676. };
  677. const settings = JSON.parse(window.localStorage.getItem("canvas-markdown-settings") ?? "{}");
  678. return {
  679. ...defaultSettings,
  680. ...settings,
  681. };
  682. }
  683. saveSettings(settings) {
  684. const existingSettings = this.loadSettings();
  685. window.localStorage.setItem("canvas-markdown-settings", JSON.stringify({
  686. ...existingSettings,
  687. ...settings,
  688. }));
  689. }
  690. applyEventListeners() {
  691. let updateTimeout;
  692. const updateData = () => {
  693. clearTimeout(updateTimeout);
  694. updateTimeout = setTimeout(() => {
  695. this.updateCanvasData();
  696. }, 500);
  697. };
  698. this.markdownTextArea.addEventListener("input", () => updateData());
  699. this.markdownEditor.on("change", () => {
  700. this.markdownTextArea.value = this.markdownEditor.getValue();
  701. updateData();
  702. });
  703. const switchButton = this.canvasSwitchEditorButton;
  704. switchButton.onclick = () => {
  705. if (this.activating)
  706. return;
  707. if (this.active)
  708. this.deactivate();
  709. };
  710. this.markdownSwitchButton.addEventListener("click", () => {
  711. if (this.active) {
  712. this.deactivate();
  713. switchButton.click();
  714. }
  715. else {
  716. this.activate();
  717. }
  718. });
  719. this.markdownSettingsButton.addEventListener("click", () => {
  720. this.displaySettings();
  721. });
  722. this.canvasFullScreenButton.onclick = () => {
  723. setTimeout(() => {
  724. this.applyCanvasResizeHandleEventListeners();
  725. this.updateEditorHeight();
  726. }, 500);
  727. };
  728. this.applyCanvasResizeHandleEventListeners();
  729. }
  730. applyCanvasResizeHandleEventListeners() {
  731. if (!this.getCanvasResizeHandle())
  732. return;
  733. this.canvasResizeHandle = this.getCanvasResizeHandle();
  734. this.canvasResizeHandle.onmousemove = () => this.updateEditorHeight();
  735. this.canvasResizeHandle.onkeydown = () => this.updateEditorHeight();
  736. }
  737. updateEditorHeight() {
  738. const height = this.canvasTextArea.style.height;
  739. this.markdownTextArea.style.height = height;
  740. this.markdownEditor.getWrapperElement().style.height = height;
  741. }
  742. activate() {
  743. this.active = true;
  744. this.activating = true;
  745. this.markdownTextContainer.style.display = "none";
  746. this.markdownPrettyContainer.style.display = "block";
  747. if (!this.isCanvasInTextMode()) {
  748. this.canvasSwitchEditorButton.click();
  749. }
  750. this.injectMarkdownSwitchTypeButton();
  751. this.mode = MarkdownEditorMode.PRETTY;
  752. if (this.markdownSwitchTypeButton) {
  753. this.markdownSwitchTypeButton.style.display = "block";
  754. this.markdownSwitchTypeButton.textContent =
  755. "Switch to Raw Markdown editor";
  756. }
  757. this.getCanvasSwitchTypeButton().style.display = "none";
  758. if (!this.isCanvasInPlainTextMode()) {
  759. this.getCanvasSwitchTypeButton().click();
  760. }
  761. const markdownCode = this.extractMarkdown(this.canvasTextArea.value);
  762. this.markdownTextArea.value = markdownCode;
  763. this.markdownEditor.setValue(markdownCode);
  764. this.canvasTextArea.parentElement.style.display = "none";
  765. this.markdownEditor.focus();
  766. this.activating = false;
  767. }
  768. deactivate() {
  769. this.active = false;
  770. this.markdownTextContainer.style.display = "none";
  771. this.markdownPrettyContainer.style.display = "none";
  772. if (this.markdownSwitchTypeButton) {
  773. this.markdownSwitchTypeButton.style.display = "none";
  774. }
  775. if (this.getCanvasSwitchTypeButton()) {
  776. this.getCanvasSwitchTypeButton().style.display = "block";
  777. }
  778. this.canvasTextArea.parentElement.style.display = "block";
  779. }
  780. async updateCanvasData() {
  781. const markdownCode = this.markdownTextArea.value, output = await this.generateOutput(markdownCode);
  782. this.canvasTextArea.value = output;
  783. this.activateCanvasCallbacks();
  784. }
  785. activateCanvasCallbacks() {
  786. const customEvent = new Event("input");
  787. customEvent.keyCode = 13;
  788. customEvent.which = 13;
  789. customEvent.location = 0;
  790. customEvent.code = "Enter";
  791. customEvent.key = "Enter";
  792. this.canvasTextArea.dispatchEvent(customEvent);
  793. }
  794. injectMarkdownUI() {
  795. const markdownSwitchButton = document.createElement("button"), switchButton = this.canvasSwitchEditorButton;
  796. markdownSwitchButton.setAttribute("type", "button");
  797. markdownSwitchButton.setAttribute("title", "Switch to Markdown editor");
  798. markdownSwitchButton.className = switchButton.className;
  799. markdownSwitchButton.setAttribute("style", switchButton.style.cssText);
  800. const markdownSwitchButtonContent = document.createElement("template");
  801. markdownSwitchButtonContent.innerHTML = `
  802. <span class="${switchButton.firstElementChild.className}">
  803. <span class="${switchButton.firstElementChild.firstElementChild.className}" style="${switchButton.firstElementChild.firstElementChild.style
  804. .cssText} direction="row" wrap="no-wrap">
  805. <span class="${switchButton.firstElementChild.firstElementChild.firstElementChild
  806. .className}">
  807. <span>M🠗</span>
  808. </span>
  809. </span>
  810. </span>
  811. `;
  812. markdownSwitchButton.append(markdownSwitchButtonContent.content.cloneNode(true));
  813. this.markdownSwitchButton = markdownSwitchButton;
  814. this.insertAfter(markdownSwitchButton, switchButton);
  815. }
  816. injectMarkdownSettingsButton() {
  817. const settingsButton = document.createElement("button"), settingsButtonContent = document.createElement("template"), switchButton = this.canvasSwitchEditorButton;
  818. settingsButton.setAttribute("type", "button");
  819. settingsButton.setAttribute("title", "Markdown settings");
  820. settingsButton.className = switchButton.className;
  821. settingsButton.setAttribute("style", switchButton.style.cssText);
  822. settingsButtonContent.innerHTML = `
  823. <span class="${switchButton.firstElementChild.className}">
  824. <span class="${switchButton.firstElementChild.firstElementChild.className}" style="${switchButton.firstElementChild.firstElementChild.style
  825. .cssText} direction="row" wrap="no-wrap">
  826. <span class="${switchButton.firstElementChild.firstElementChild.firstElementChild
  827. .className}">
  828. <span>M⚙</span>
  829. </span>
  830. </span>
  831. </span>
  832. `;
  833. settingsButton.append(settingsButtonContent.content.cloneNode(true));
  834. this.markdownSettingsButton = settingsButton;
  835. this.insertAfter(settingsButton, switchButton);
  836. }
  837. injectMarkdownSwitchTypeButton() {
  838. if (this.markdownSwitchTypeButton?.isConnected)
  839. return;
  840. const button = document.createElement("button"), switchButton = this.getCanvasSwitchTypeButton();
  841. button.setAttribute("type", "button");
  842. button.className = switchButton.className;
  843. button.setAttribute("style", switchButton.style.cssText);
  844. const buttonContent = document.createElement("template");
  845. buttonContent.innerHTML = `
  846. <span class="${switchButton.firstElementChild.className}">
  847. <span class="${switchButton.firstElementChild.firstElementChild.className}" md-id="md-switch-type-button">
  848. Switch to raw Markdown editor
  849. </span>
  850. </span>
  851. `;
  852. button.append(buttonContent.content.cloneNode(true));
  853. this.markdownSwitchTypeButton = button;
  854. this.insertAfter(button, switchButton);
  855. this.markdownSwitchTypeButton.addEventListener("click", () => {
  856. if (!this.active)
  857. return;
  858. if (this.mode === MarkdownEditorMode.PRETTY) {
  859. this.mode = MarkdownEditorMode.RAW;
  860. this.markdownPrettyContainer.style.display = "none";
  861. this.markdownTextContainer.style.display = "block";
  862. this.markdownSwitchTypeButton.textContent =
  863. "Switch to Pretty Markdown editor";
  864. }
  865. else {
  866. this.mode = MarkdownEditorMode.PRETTY;
  867. this.markdownPrettyContainer.style.display = "block";
  868. this.markdownTextContainer.style.display = "none";
  869. this.markdownSwitchTypeButton.textContent =
  870. "Switch to Raw Markdown editor";
  871. }
  872. });
  873. }
  874. /**
  875. * Extracts the markdown code from the html comment.
  876. */
  877. extractMarkdown(html) {
  878. let match = html.match(/<span class="canvas-markdown-code"[^\n]*?>\s*([\w+./=]*)\s*<\/span>/)?.[1];
  879. if (this.encodedOutput) {
  880. match = this.encodedOutput;
  881. }
  882. if (!match)
  883. return "";
  884. const decoded = atob(match);
  885. if (/\u0000/.test(decoded))
  886. return fromBinary(decoded);
  887. else
  888. return decoded;
  889. }
  890. async generateOutput(markdown) {
  891. const initialHTML = this.showdownConverter.makeHtml(markdown), outputHTML = await this.highlightCode(initialHTML), settings = this.loadSettings();
  892. let encoded;
  893. try {
  894. encoded = btoa(markdown);
  895. }
  896. catch (e) {
  897. encoded = btoa(toBinary(markdown));
  898. }
  899. this.encodedOutput = encoded;
  900. if (settings.removeMarkdownBackup) {
  901. return outputHTML;
  902. }
  903. else {
  904. return `${outputHTML}
  905. <span class="canvas-markdown-code" style="display: none;">${encoded}</span>`;
  906. }
  907. }
  908. async highlightCode(html) {
  909. const template = document.createElement("template");
  910. template.innerHTML = html;
  911. const codeBlocks = [
  912. ...template.content.querySelectorAll("pre code"),
  913. ];
  914. await this.extractLanguages(codeBlocks);
  915. for (const codeBlock of codeBlocks) {
  916. highlight.highlightElement(codeBlock);
  917. }
  918. // Remove katex-html
  919. const katexHTMLElements = template.content.querySelectorAll(".katex-html");
  920. for (const element of katexHTMLElements) {
  921. element.remove();
  922. }
  923. // handle tasklists
  924. const taskListItems = template.content.querySelectorAll(".task-list-item");
  925. for (const item of taskListItems) {
  926. const checkbox = item.querySelector("input[type=checkbox]"), checked = checkbox.checked, replacement = document.createElement("span");
  927. replacement.style.cssText = checkbox.style.cssText;
  928. replacement.style.display = "inline-block";
  929. replacement.style.width = "1rem";
  930. replacement.style.height = "1rem";
  931. replacement.style.border = "2px solid #ccc";
  932. replacement.style.borderRadius = "25%";
  933. if (checked) {
  934. replacement.style.backgroundColor = "#0099ff";
  935. replacement.className = "task-list-item-checked";
  936. }
  937. replacement.innerHTML = "&nbsp;";
  938. item.insertBefore(replacement, checkbox);
  939. checkbox.remove();
  940. }
  941. // Extract styles from custom settings
  942. const settings = this.loadSettings();
  943. for (const setting of settings.customStyles) {
  944. const { target, style } = setting;
  945. const targetElements = template.content.querySelectorAll(target);
  946. for (const targetElement of targetElements) {
  947. targetElement.style.cssText += style;
  948. }
  949. }
  950. return this.extractStyles(template);
  951. }
  952. extractStyles(template) {
  953. const tempDiv = document.createElement("pre"), tempCode = document.createElement("code");
  954. tempCode.className = "hljs";
  955. tempDiv.append(tempCode);
  956. tempDiv.style.display = "none";
  957. document.body.append(tempDiv);
  958. const hljsElements = [
  959. ...template.content.querySelectorAll("pre [class*=hljs]"),
  960. ];
  961. for (const element of hljsElements) {
  962. let hasOnErrorAttribute = false, onErrorValue = null;
  963. if (element.hasAttribute("onerror")) {
  964. hasOnErrorAttribute = true;
  965. onErrorValue = element.getAttribute("onerror");
  966. element.removeAttribute("onerror");
  967. }
  968. const testElement = tempCode.appendChild(element.cloneNode(false));
  969. if (hasOnErrorAttribute) {
  970. testElement.setAttribute("onerror", onErrorValue);
  971. }
  972. if (element.tagName === "CODE") {
  973. tempDiv.append(testElement);
  974. element.parentElement.style.backgroundColor =
  975. getComputedStyle(testElement).backgroundColor;
  976. element.style.textShadow = "none";
  977. element.style.display = "block";
  978. element.style.overflowX = "auto";
  979. element.style.padding = "1em";
  980. }
  981. const computedStyle = getComputedStyle(testElement), specialClasses = {
  982. "hljs-deletion": "background-color",
  983. "hljs-addition": "background-color",
  984. "hljs-emphasis": "font-style",
  985. "hljs-strong": "font-weight",
  986. "hljs-section": "font-weight",
  987. };
  988. element.style.color = computedStyle.color;
  989. for (const [className, style] of Object.entries(specialClasses)) {
  990. if (testElement.classList.contains(className)) {
  991. element.style.setProperty(style, computedStyle.getPropertyValue(style));
  992. }
  993. }
  994. testElement.remove();
  995. }
  996. const output = template.innerHTML;
  997. tempDiv.remove();
  998. return output;
  999. }
  1000. async extractLanguages(codeBlocks) {
  1001. for (const block of codeBlocks) {
  1002. const language = block.className.match(/language-([^\s]*)/)?.[1];
  1003. if (language && !highlight.getLanguage(language) && languages[language]) {
  1004. const languageData = (await import(`https://cdn.jsdelivr.net/gh/theusaf/canvas-markdown@5216c569489b9aa2caa6aee49ef8aadabb1f1794/lib/highlight/es/languages/${languages[language]}.min.js`).catch(() => ({}))).default;
  1005. if (languageData) {
  1006. highlight.registerLanguage(language, languageData);
  1007. }
  1008. }
  1009. }
  1010. }
  1011. }