Browse Source

Add markdown preview

Peter Justin 3 years ago
parent
commit
f3b30451fa

+ 4 - 2
flaskbb/templates/_macros/form.html

@@ -124,13 +124,15 @@
                         <md-ordered-list class="btn btn-white" data-tooltip="tooltip" title="Ordered List"><span class="fas fa-list-ol"></span></md-ordered-list>
                         <md-mention class="btn btn-white" data-tooltip="tooltip" title="Mention"><span class="fas fa-at"></span></md-mention>
                     </div>
+
+                    <button class="btn btn-sm btn-primary me-2 preview-btn" data-preview="{{ field.id }}">Preview</button>
+                    <button type="button" class="btn btn-sm btn-white help-btn" data-bs-toggle="modal" data-bs-target="#editor-help" data-tooltip="tooltip" title="Markdown Cheatsheet"><span class="fas fa-question"></span></button>
                 </markdown-toolbar>
 
-                <button class="btn btn-sm btn-primary me-2" for="{{ field.id }}">Preview</button>
-                <button type="button" class="btn btn-sm btn-white" data-bs-toggle="modal" data-bs-target="#editor-help" data-tooltip="tooltip" title="Markdown Cheatsheet"><span class="fas fa-question"></span></button>
             </div>
         </div>
         {{ field(class=css_class, placeholder=placeholder, **kwargs) }}
+        <div class="preview" id="{{ field.id }}-preview" style="display: none; padding:  0.375rem 0.75rem"></div>
     </div>
     {{ field_description(field) }}
     {{ field_errors(field) }}

+ 28 - 0
flaskbb/themes/aurora/package-lock.json

@@ -15,6 +15,8 @@
         "@textcomplete/core": "^0.1.9",
         "@textcomplete/textarea": "^0.1.9",
         "bootstrap": "^5.1.0",
+        "dompurify": "^2.3.1",
+        "marked": "^3.0.2",
         "twemoji": "^13.1.0"
       },
       "devDependencies": {
@@ -3057,6 +3059,11 @@
         "node": ">=6"
       }
     },
+    "node_modules/dompurify": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.3.1.tgz",
+      "integrity": "sha512-xGWt+NHAQS+4tpgbOAI08yxW0Pr256Gu/FNE2frZVTbgrBUn8M7tz7/ktS/LZ2MHeGqz6topj0/xY+y8R5FBFw=="
+    },
     "node_modules/electron-to-chromium": {
       "version": "1.3.830",
       "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.830.tgz",
@@ -4347,6 +4354,17 @@
         "node": ">=0.10.0"
       }
     },
+    "node_modules/marked": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/marked/-/marked-3.0.2.tgz",
+      "integrity": "sha512-TMJQQ79Z0e3rJYazY0tIoMsFzteUGw9fB3FD+gzuIT3zLuG9L9ckIvUfF51apdJkcqc208jJN2KbtPbOvXtbjA==",
+      "bin": {
+        "marked": "bin/marked"
+      },
+      "engines": {
+        "node": ">= 12"
+      }
+    },
     "node_modules/merge-stream": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
@@ -8793,6 +8811,11 @@
         "rimraf": "^2.6.3"
       }
     },
+    "dompurify": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.3.1.tgz",
+      "integrity": "sha512-xGWt+NHAQS+4tpgbOAI08yxW0Pr256Gu/FNE2frZVTbgrBUn8M7tz7/ktS/LZ2MHeGqz6topj0/xY+y8R5FBFw=="
+    },
     "electron-to-chromium": {
       "version": "1.3.830",
       "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.830.tgz",
@@ -9783,6 +9806,11 @@
         "object-visit": "^1.0.0"
       }
     },
+    "marked": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/marked/-/marked-3.0.2.tgz",
+      "integrity": "sha512-TMJQQ79Z0e3rJYazY0tIoMsFzteUGw9fB3FD+gzuIT3zLuG9L9ckIvUfF51apdJkcqc208jJN2KbtPbOvXtbjA=="
+    },
     "merge-stream": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",

+ 2 - 0
flaskbb/themes/aurora/package.json

@@ -29,6 +29,8 @@
     "@textcomplete/core": "^0.1.9",
     "@textcomplete/textarea": "^0.1.9",
     "bootstrap": "^5.1.0",
+    "dompurify": "^2.3.1",
+    "marked": "^3.0.2",
     "twemoji": "^13.1.0"
   },
   "devDependencies": {

+ 108 - 47
flaskbb/themes/aurora/src/app/editor.js

@@ -1,63 +1,113 @@
 import { TextareaEditor } from "@textcomplete/textarea";
 import { Textcomplete } from "@textcomplete/core";
 import EMOJIS from "./emoji";
+import { hideElement, isHidden, showElement } from "./utils";
 import { parse_emoji } from "./flaskbb";
+import marked from "marked";
+import DOMPurify from "dompurify";
 
-const TEXTCOMPLETE_CONFIG = {
-    dropdown: {
-        maxCount: 5
+const buttonSelectors = [
+    "md-header",
+    "md-bold",
+    "md-italic",
+    "md-quote",
+    "md-code",
+    "md-link",
+    "md-image",
+    "md-unordered-list",
+    "md-ordered-list",
+    "md-task-list",
+    "md-mention",
+    "md-strikethrough",
+    ".help-btn",
+];
+function disableButtons(toolbar) {
+    for (const button of toolbar.querySelectorAll(buttonSelectors.join(", "))) {
+        button.classList.add("disabled");
     }
 }
 
-const EMOJI_STRATEGY = {
-    id: "emoji",
-    match: /\B:([\-+\w]*)$/,
-    search: (term, callback) => {
-        callback(EMOJIS.map(value => {
-            return value[0].indexOf(term) !== -1 ? { character: value[1], name: value[0] } : null;
-        }))
-    },
-    replace: (value) => {
-        return `${value.character} `;
-    },
-    template: (value) => {
-        return parse_emoji(value.character) + ' ' + value.name;
-    },
-    context: (text) => {
-        const blockmatch = text.match(/`{3}/g)
-        if (blockmatch && blockmatch.length % 2) {
-            // Cursor is in a code block
-            return false
-        }
-        const inlinematch = text.match(/`/g)
-        if (inlinematch && inlinematch.length % 2) {
-            // Cursor is in a inline code
-            return false
-        }
-        return true
-    },
+function activateButtons(toolbar) {
+    for (const button of toolbar.querySelectorAll(buttonSelectors.join(", "))) {
+        button.classList.remove("disabled");
+    }
 }
 
-function configureAutocomplete(element) {
-    const editor = new TextareaEditor(element)
-    const textcomplete = new Textcomplete(editor, [EMOJI_STRATEGY], TEXTCOMPLETE_CONFIG)
-}
+function markdownPreview(element) {
+    const editorId = element.dataset.preview
+    const toolbar = document.querySelector(`markdown-toolbar[for="${editorId}"]`)
+    const markdownContainer = document.querySelector(
+        `#${editorId}`
+    );
+    const previewContainer = document.querySelector(
+        `#${editorId}-preview`
+    );
 
+    const content = markdownContainer.value;
+    let renderedContent = "";
+    if (isHidden(previewContainer)) {
+        renderedContent = marked(content);
+        renderedContent = DOMPurify.sanitize(renderedContent);
+        renderedContent = parse_emoji(renderedContent);
 
-function setupEditor() {
-    const editors = document.querySelectorAll(".flaskbb-editor");
-    for(const e of editors) {
-        configureAutocomplete(e);
+        previewContainer.style.minHeight = `${markdownContainer.scrollHeight}px`;
+        previewContainer.style.height = "auto";
+
+        previewContainer.innerHTML = renderedContent;
+
+        disableButtons(toolbar);
+        hideElement(markdownContainer);
+        showElement(previewContainer);
+    } else {
+        activateButtons(toolbar);
+        showElement(markdownContainer);
+        hideElement(previewContainer);
     }
 }
 
+function autocomplete(element) {
+    const config = {
+        dropdown: {
+            maxCount: 5,
+        },
+    };
 
-function markdownPreview(element) {
-
+    const emojiStrategy = {
+        id: "emoji",
+        match: /\B:([\-+\w]*)$/,
+        search: (term, callback) => {
+            callback(
+                EMOJIS.map((value) => {
+                    return value[0].indexOf(term) !== -1
+                        ? { character: value[1], name: value[0] }
+                        : null;
+                })
+            );
+        },
+        replace: (value) => {
+            return `${value.character} `;
+        },
+        template: (value) => {
+            return parse_emoji(value.character) + " " + value.name;
+        },
+        context: (text) => {
+            const blockmatch = text.match(/`{3}/g);
+            if (blockmatch && blockmatch.length % 2) {
+                // Cursor is in a code block
+                return false;
+            }
+            const inlinematch = text.match(/`/g);
+            if (inlinematch && inlinematch.length % 2) {
+                // Cursor is in a inline code
+                return false;
+            }
+            return true;
+        },
+    };
+    return new Textcomplete(new TextareaEditor(element), [emojiStrategy], config);
 }
 
-
-function autoresizeTextarea(element) {
+function autoresize(element) {
     element.setAttribute(
         "style",
         "height:" + element.scrollHeight + "px;overflow-y:hidden;"
@@ -65,7 +115,6 @@ function autoresizeTextarea(element) {
     element.addEventListener(
         "input",
         function (e) {
-            console.log(e)
             e.target.style.height = "auto";
             e.target.style.height = e.target.scrollHeight + "px";
         },
@@ -73,9 +122,21 @@ function autoresizeTextarea(element) {
     );
 }
 
-const tareas = document.querySelectorAll("[data-autoresize=true]");
-for (const e of tareas) {
-    autoresizeTextarea(e);
+function setupEditor() {
+    document.querySelectorAll(".flaskbb-editor").forEach((el) => {
+        autocomplete(el);
+    });
+
+    document.querySelectorAll(".preview-btn").forEach((el) => {
+        el.addEventListener("click", (event) => {
+            event.preventDefault();
+            markdownPreview(el);
+        })
+    });
+
+    document.querySelectorAll("[data-autoresize=true]").forEach((el) => {
+        autoresize(el);
+    });
 }
 
-setupEditor()
+setupEditor();

+ 5 - 9
flaskbb/themes/aurora/src/app/flaskbb.js

@@ -5,6 +5,8 @@
  */
 import { Modal } from "bootstrap";
 import twemoji from "twemoji";
+import { isHidden } from "./utils";
+
 
 // get the csrf token from the header
 let csrf_token = document.querySelector("meta[name=csrf-token]").content;
@@ -12,7 +14,7 @@ let csrf_token = document.querySelector("meta[name=csrf-token]").content;
 export function show_management_search() {
     let form = document.querySelector(".search-form");
 
-    if (window.getComputedStyle(form).display === "none") {
+    if (isHidden(form)) {
         form.style.display = "block";
         form.querySelector("input").focus();
     } else {
@@ -104,15 +106,9 @@ export function send_data(endpoint_url, data) {
 
                     let reverse_html = "";
                     if (obj.reverse == "ban") {
-                        reverse_html =
-                            '<span class="fas fa-flag text-success" data-bs-toggle="tooltip" title="' +
-                            obj.reverse_name +
-                            '"></span>';
+                        reverse_html = `<span class="fas fa-flag text-success" data-bs-toggle="tooltip" title="${obj.reverse_name}"></span>`;
                     } else if (obj.reverse == "unban") {
-                        reverse_html =
-                            '<span class="fas fa-flag text-warning" data-bs-toggle="tooltip" title="' +
-                            obj.reverse_name +
-                            '"></span>';
+                        reverse_html = `<span class="fas fa-flag text-warning" data-bs-toggle="tooltip" title="${obj.reverse_name}"></span>`;
                     }
                     form.querySelector("button").innerHTML = reverse_html;
                 } else if (obj.type == "delete") {

+ 11 - 0
flaskbb/themes/aurora/src/app/utils.js

@@ -0,0 +1,11 @@
+export function isHidden(element) {
+    return window.getComputedStyle(element).display === "none";
+}
+
+export function hideElement(element) {
+    element.style.display = "none";
+}
+
+export function showElement(element) {
+    element.style.display = "block";
+}