// ==UserScript==
// @name Canvas Markdown
// @namespace https://theusaf.org
// @version 1.3.3
// @description Adds a markdown editor to Canvas
// @author theusaf
// @supportURL https://github.com/theusaf/canvas-markdown/issues
// @copyright (c) 2023 theusaf
// @homepage https://github.com/theusaf/canvas-markdown
// @license MIT
// @match https://*/*
// @grant none
// ==/UserScript==
let highlight, languages;
try {
if (new URL(document.querySelector("#global_nav_help_link")
?.href ?? "")?.hostname === "help.instructure.com") {
console.log("[Canvas Markdown] Detected Canvas page, loading...");
(async () => {
console.log("[Canvas Markdown] Importing dependencies...");
await import("https://cdn.jsdelivr.net/gh/theusaf/canvas-markdown@5216c569489b9aa2caa6aee49ef8aadabb1f1794/lib/codemirror/codemirror.js");
await import("https://cdn.jsdelivr.net/gh/theusaf/canvas-markdown@5216c569489b9aa2caa6aee49ef8aadabb1f1794/lib/codemirror/mode/markdown/markdown.js");
highlight = (await import("https://cdn.jsdelivr.net/gh/theusaf/canvas-markdown@5216c569489b9aa2caa6aee49ef8aadabb1f1794/lib/highlight/es/core.min.js")).default;
languages = (await import("https://cdn.jsdelivr.net/gh/theusaf/canvas-markdown@5216c569489b9aa2caa6aee49ef8aadabb1f1794/lib/highlight/languages.js")).default;
const s = document.createElement("script");
s.src =
"https://cdn.jsdelivr.net/npm/[email protected]/dist/showdown.min.js";
document.head.append(s);
const codemirrorCSS = document.createElement("link");
codemirrorCSS.rel = "stylesheet";
codemirrorCSS.href =
"https://cdn.jsdelivr.net/gh/theusaf/canvas-markdown@5216c569489b9aa2caa6aee49ef8aadabb1f1794/lib/codemirror/codemirror.css";
const highlightCSS = document.createElement("link");
highlightCSS.rel = "stylesheet";
highlightCSS.href =
"https://cdn.jsdelivr.net/gh/theusaf/canvas-markdown@5216c569489b9aa2caa6aee49ef8aadabb1f1794/lib/highlight/styles/github-dark.min.css";
document.head.append(highlightCSS);
document.head.append(codemirrorCSS);
console.log("[Canvas Markdown] Setting up...");
setupWatcher();
console.log("[Canvas Markdown] Done.");
})();
}
else {
console.log("[Canvas Markdown] Not a Canvas page, skipping...");
}
}
catch (e) {
/* ignore */
}
function getEditorElements() {
return [
...document.querySelectorAll(".ic-RichContentEditor:not([md-id=canvas-container])"),
];
}
function setupWatcher() {
setInterval(() => {
const potentialEditorElements = getEditorElements();
if (potentialEditorElements.length) {
for (const editorElement of potentialEditorElements) {
const markdownEditor = new MarkdownEditor(editorElement);
markdownEditor.setup();
}
}
}, 1e3);
}
var MarkdownEditorMode;
(function (MarkdownEditorMode) {
MarkdownEditorMode[MarkdownEditorMode["RAW"] = 0] = "RAW";
MarkdownEditorMode[MarkdownEditorMode["PRETTY"] = 1] = "PRETTY";
})(MarkdownEditorMode || (MarkdownEditorMode = {}));
class MarkdownEditor {
editorContainer;
canvasTextArea;
canvasResizeHandle;
canvasSwitchEditorButton;
canvasFullScreenButton;
markdownTextContainer;
markdownPrettyContainer;
markdownTextArea;
markdownEditor;
markdownSwitchButton;
markdownSwitchTypeButton;
showdownConverter;
active = false;
mode = MarkdownEditorMode.PRETTY;
activating = false;
constructor(editor) {
this.editorContainer = editor;
this.canvasTextArea = this.getCanvasTextArea();
this.canvasResizeHandle = this.getCanvasResizeHandle();
this.canvasSwitchEditorButton = this.getCanvasSwitchEditorButton();
this.canvasFullScreenButton = this.editorContainer.querySelector("[data-btn-id=rce-fullscreen-btn]");
}
setup() {
this.editorContainer.setAttribute("md-id", "canvas-container");
this.injectMarkdownEditor();
this.setupShowdown();
this.injectMarkdownUI();
this.applyEventListeners();
}
setupShowdown() {
showdown.setFlavor("github");
this.showdownConverter = new showdown.Converter({
ghMentions: false,
});
}
getCanvasResizeHandle() {
return this.editorContainer.querySelector("[data-btn-id=rce-resize-handle]");
}
getCanvasTextArea() {
return this.editorContainer.querySelector("textarea[data-rich_text=true]");
}
getCanvasSwitchEditorButton() {
return this.editorContainer.querySelector("[data-btn-id=rce-edit-btn]");
}
isCanvasInTextMode() {
return /rich text/i.test(this.canvasSwitchEditorButton.title);
}
getCanvasSwitchTypeButton() {
return this.editorContainer.querySelector("[data-btn-id=rce-editormessage-btn]");
}
isCanvasInPlainTextMode() {
return /pretty html/i.test(this.getCanvasSwitchTypeButton().textContent);
}
insertAfter(newNode, referenceNode) {
referenceNode.parentNode.insertBefore(newNode, referenceNode.nextSibling);
}
injectMarkdownEditor() {
const editorContent = document.createElement("template");
// Note: The heights should follow the same values as the canvas editor.
// These values can also be changed by the user.
editorContent.innerHTML = `
<div md-id="markdown-editor-text-container" style="display: none;">
<textarea md-id="markdown-editor" style="height: 400px; resize: none;"></textarea>
</div>
<div md-id="markdown-editor-pretty-container">
<div class="RceHtmlEditor">
<div>
<label>
<span></span>
<div class="react-codemirror2" md-id="markdown-editor-codemirror-container">
<!-- Insert CodeMirror editor here -->
</div>
</label>
</div>
</div>
</div>
`;
this.editorContainer
.querySelector(".rce-wrapper")
.prepend(editorContent.content.cloneNode(true));
this.markdownTextContainer = this.editorContainer.querySelector("[md-id=markdown-editor-text-container]");
this.markdownTextArea = this.editorContainer.querySelector("[md-id=markdown-editor]");
this.markdownPrettyContainer = this.editorContainer.querySelector("[md-id=markdown-editor-pretty-container]");
this.markdownEditor = CodeMirror(this.markdownPrettyContainer.querySelector("[md-id=markdown-editor-codemirror-container]"), {
mode: "markdown",
lineNumbers: true,
lineWrapping: true,
});
const codeMirrorEditor = this.markdownEditor.getWrapperElement();
codeMirrorEditor.style.height = "400px";
codeMirrorEditor.setAttribute("md-id", "markdown-editor-codemirror");
// Hide the markdown editor. By doing it here, it also allows CodeMirror to
// properly render when the editor is shown.
this.markdownPrettyContainer.style.display = "none";
}
applyEventListeners() {
this.markdownTextArea.addEventListener("input", () => this.updateCanvasData());
this.markdownEditor.on("change", () => {
this.markdownTextArea.value = this.markdownEditor.getValue();
this.updateCanvasData();
});
const switchButton = this.canvasSwitchEditorButton;
switchButton.onclick = () => {
if (this.activating)
return;
if (this.active)
this.deactivate();
};
this.markdownSwitchButton.addEventListener("click", () => {
if (this.active) {
this.deactivate();
switchButton.click();
}
else {
this.activate();
}
});
this.canvasFullScreenButton.onclick = () => {
setTimeout(() => {
this.applyCanvasResizeHandleEventListeners();
this.updateEditorHeight();
}, 500);
};
this.applyCanvasResizeHandleEventListeners();
}
applyCanvasResizeHandleEventListeners() {
if (!this.getCanvasResizeHandle())
return;
this.canvasResizeHandle = this.getCanvasResizeHandle();
this.canvasResizeHandle.onmousemove = () => this.updateEditorHeight();
this.canvasResizeHandle.onkeydown = () => this.updateEditorHeight();
}
updateEditorHeight() {
const height = this.canvasTextArea.style.height;
this.markdownTextArea.style.height = height;
this.markdownEditor.getWrapperElement().style.height = height;
}
activate() {
this.active = true;
this.activating = true;
this.markdownTextContainer.style.display = "none";
this.markdownPrettyContainer.style.display = "block";
if (!this.isCanvasInTextMode()) {
this.canvasSwitchEditorButton.click();
}
this.injectMarkdownSwitchTypeButton();
this.mode = MarkdownEditorMode.PRETTY;
if (this.markdownSwitchTypeButton) {
this.markdownSwitchTypeButton.style.display = "block";
this.markdownSwitchTypeButton.textContent =
"Switch to Raw Markdown editor";
}
this.getCanvasSwitchTypeButton().style.display = "none";
if (!this.isCanvasInPlainTextMode()) {
this.getCanvasSwitchTypeButton().click();
}
const markdownCode = this.extractMarkdown(this.canvasTextArea.value);
this.markdownTextArea.value = markdownCode;
this.markdownEditor.setValue(markdownCode);
this.canvasTextArea.parentElement.style.display = "none";
this.markdownEditor.focus();
this.activating = false;
}
deactivate() {
this.active = false;
this.markdownTextContainer.style.display = "none";
this.markdownPrettyContainer.style.display = "none";
if (this.markdownSwitchTypeButton) {
this.markdownSwitchTypeButton.style.display = "none";
}
if (this.getCanvasSwitchTypeButton()) {
this.getCanvasSwitchTypeButton().style.display = "block";
}
this.canvasTextArea.parentElement.style.display = "block";
}
async updateCanvasData() {
const markdownCode = this.markdownTextArea.value, output = await this.generateOutput(markdownCode);
this.canvasTextArea.value = output;
this.activateCanvasCallbacks();
}
activateCanvasCallbacks() {
const customEvent = new Event("input");
customEvent.keyCode = 13;
customEvent.which = 13;
customEvent.location = 0;
customEvent.code = "Enter";
customEvent.key = "Enter";
this.canvasTextArea.dispatchEvent(customEvent);
}
injectMarkdownUI() {
const button = document.createElement("button"), switchButton = this.canvasSwitchEditorButton;
button.setAttribute("type", "button");
button.className = switchButton.className;
button.setAttribute("style", switchButton.style.cssText);
const buttonContent = document.createElement("template");
buttonContent.innerHTML = `
<span class="${switchButton.firstElementChild.className}">
<span class="${switchButton.firstElementChild.firstElementChild.className}" style="${switchButton.firstElementChild.firstElementChild.style
.cssText} direction="row" wrap="no-wrap">
<span class="${switchButton.firstElementChild.firstElementChild.firstElementChild
.className}">
<span>M🠗</span>
</span>
</span>
</span>
`;
button.append(buttonContent.content.cloneNode(true));
this.markdownSwitchButton = button;
this.insertAfter(button, switchButton);
}
injectMarkdownSwitchTypeButton() {
if (this.markdownSwitchTypeButton?.isConnected)
return;
const button = document.createElement("button"), switchButton = this.getCanvasSwitchTypeButton();
button.setAttribute("type", "button");
button.className = switchButton.className;
button.setAttribute("style", switchButton.style.cssText);
const buttonContent = document.createElement("template");
buttonContent.innerHTML = `
<span class="${switchButton.firstElementChild.className}">
<span class="${switchButton.firstElementChild.firstElementChild.className}" md-id="md-switch-type-button">Switch to raw Markdown editor</span>
</span>
`;
button.append(buttonContent.content.cloneNode(true));
this.markdownSwitchTypeButton = button;
this.insertAfter(button, switchButton);
this.markdownSwitchTypeButton.addEventListener("click", () => {
if (!this.active)
return;
if (this.mode === MarkdownEditorMode.PRETTY) {
this.mode = MarkdownEditorMode.RAW;
this.markdownPrettyContainer.style.display = "none";
this.markdownTextContainer.style.display = "block";
this.markdownSwitchTypeButton.textContent =
"Switch to Pretty Markdown editor";
}
else {
this.mode = MarkdownEditorMode.PRETTY;
this.markdownPrettyContainer.style.display = "block";
this.markdownTextContainer.style.display = "none";
this.markdownSwitchTypeButton.textContent =
"Switch to Raw Markdown editor";
}
});
}
/**
* Extracts the markdown code from the html comment.
*/
extractMarkdown(html) {
const legacyMatch = html.match(/<!--CANVAS-MARKDOWN-CODE[^\n]*\n(.*)\nCANVAS-MARKDOWN-CODE-->\s*$/s)?.[1];
if (legacyMatch)
return legacyMatch;
const match = html.match(/<span class="canvas-markdown-code"[^\n]*?>\s*([\w=]*)\s*<\/span>/)?.[1];
if (!match)
return "";
return atob(match);
}
async generateOutput(markdown) {
const initialHTML = this.showdownConverter.makeHtml(markdown), outputHTML = await this.highlightCode(initialHTML);
return `${outputHTML}
<span class="canvas-markdown-code" style="display: none;">${btoa(markdown)}</span>`;
}
async highlightCode(html) {
const template = document.createElement("template");
template.innerHTML = html;
const codeBlocks = [
...template.content.querySelectorAll("pre code"),
];
await this.extractLanguages(codeBlocks);
for (const codeBlock of codeBlocks) {
highlight.highlightElement(codeBlock);
}
return this.extractStyles(template);
}
extractStyles(template) {
const tempDiv = document.createElement("pre"), tempCode = document.createElement("code");
tempCode.className = "hljs";
tempDiv.append(tempCode);
tempDiv.style.display = "none";
document.body.append(tempDiv);
const hljsElements = [
...template.content.querySelectorAll("pre [class*=hljs]"),
];
for (const element of hljsElements) {
let hasOnErrorAttribute = false, onErrorValue = null;
if (element.hasAttribute("onerror")) {
hasOnErrorAttribute = true;
onErrorValue = element.getAttribute("onerror");
element.removeAttribute("onerror");
}
const testElement = tempCode.appendChild(element.cloneNode(false));
if (hasOnErrorAttribute) {
testElement.setAttribute("onerror", onErrorValue);
}
if (element.tagName === "CODE") {
// Append the code element to the pre element.
tempDiv.append(testElement);
element.parentElement.style.backgroundColor =
getComputedStyle(testElement).backgroundColor;
element.style.textShadow = "none";
element.style.display = "block";
element.style.overflowX = "auto";
element.style.padding = "1em";
}
const computedStyle = getComputedStyle(testElement), specialClasses = {
"hljs-deletion": "background-color",
"hljs-addition": "background-color",
"hljs-emphasis": "font-style",
"hljs-strong": "font-weight",
"hljs-section": "font-weight",
};
element.style.color = computedStyle.color;
for (const [className, style] of Object.entries(specialClasses)) {
if (testElement.classList.contains(className)) {
element.style.setProperty(style, computedStyle.getPropertyValue(style));
}
}
testElement.remove();
}
const output = template.innerHTML;
tempDiv.remove();
return output;
}
async extractLanguages(codeBlocks) {
for (const block of codeBlocks) {
const language = block.className.match(/language-([^\s]*)/)?.[1];
if (language && !highlight.getLanguage(language) && languages[language]) {
const languageData = (await import(`https://cdn.jsdelivr.net/gh/theusaf/canvas-markdown@5216c569489b9aa2caa6aee49ef8aadabb1f1794/lib/highlight/es/languages/${languages[language]}.min.js`).catch(() => ({}))).default;
if (languageData) {
highlight.registerLanguage(language, languageData);
}
}
}
}
}