]> BookStack Code Mirror - bookstack/commitdiff
Got underline working in editor
authorDan Brown <redacted>
Mon, 10 Jan 2022 13:38:32 +0000 (13:38 +0000)
committerDan Brown <redacted>
Mon, 10 Jan 2022 13:38:32 +0000 (13:38 +0000)
Major step, since this is the first inline HTML element which needed
advanced parsing out on the markdown side, since not commonmark
supported.

package.json
resources/js/editor/MarkdownView.js
resources/js/editor/markdown-parser.js
resources/js/editor/markdown-serializer.js
resources/js/editor/menu/index.js
resources/js/editor/schema.js
resources/js/editor/util.js
resources/views/editor-test.blade.php

index fdf65f63b6b7d52c112ee18ccc32705903562d5e..c4ea1a302b33371e4077e53a73b2b9d339308b2b 100644 (file)
@@ -7,8 +7,8 @@
     "build:js:dev": "esbuild --bundle ./resources/js/index.js --outfile=public/dist/app.js --sourcemap --target=es2019 --main-fields=module,main",
     "build:js:watch": "chokidar --initial \"./resources/**/*.js\" -c \"npm run build:js:dev\"",
     "build:js:production": "NODE_ENV=production esbuild --bundle ./resources/js/index.js --outfile=public/dist/app.js --sourcemap --target=es2019 --main-fields=module,main --minify",
-    "build:js:editor:dev": "esbuild --bundle ./resources/js/editor.js --outfile=public/dist/editor.js --sourcemap --target=es2019 --main-fields=module,main",
-    "build:js:editor:watch": "chokidar --initial \"./resources/js/editor.js\" \"./resources/js/editor/**/*.js\" -c \"npm run build:js:editor:dev\"",
+    "build:js_editor:dev": "esbuild --bundle ./resources/js/editor.js --outfile=public/dist/editor.js --sourcemap --target=es2019 --main-fields=module,main",
+    "build:js_editor:watch": "chokidar --initial \"./resources/js/editor.js\" \"./resources/js/editor/**/*.js\" -c \"npm run build:js_editor:dev\"",
     "build": "npm-run-all --parallel build:*:dev",
     "production": "npm-run-all --parallel build:*:production",
     "dev": "npm-run-all --parallel watch livereload",
index f952c8606e99453e367ca4f0f38c3eecadd915e0..88fc1fff54f3389a850faee2c949fd21d33eaa9e 100644 (file)
@@ -11,6 +11,8 @@ class MarkdownView {
 
         this.textarea = target.appendChild(document.createElement("textarea"))
         this.textarea.value = markdown;
+        this.textarea.style.width = '1000px';
+        this.textarea.style.height = '1000px';
     }
 
     get content() {
index 46495a7e00e77c2b54fdfddfb0039990c62a6e21..b198ffad43f27b433ce4affb74529fc7972d993b 100644 (file)
@@ -1,17 +1,20 @@
 import schema from "./schema";
 import markdownit from "markdown-it";
 import {MarkdownParser, defaultMarkdownParser} from "prosemirror-markdown";
-import {htmlToDoc} from "./util";
+import {htmlToDoc, KeyedMultiStack} from "./util";
 
 const tokens = defaultMarkdownParser.tokens;
 
-// This is really a placeholder on the object to allow the below
-// parser.tokenHandlers.html_block hack to work as desired.
+// These are really a placeholder on the object to allow the below
+// parser.tokenHandlers.html_[block/inline] hacks to work as desired.
 tokens.html_block = {block: "callout", noCloseToken: true};
+tokens.html_inline = {mark: "underline"};
 
 const tokenizer = markdownit("commonmark", {html: true});
 const parser = new MarkdownParser(schema, tokenizer, tokens);
 
+// When we come across HTML blocks we use the document schema to parse them
+// into nodes then re-add those back into the parser state.
 parser.tokenHandlers.html_block = function(state, tok, tokens, i) {
     const contentDoc = htmlToDoc(tok.content || '');
     for (const node of contentDoc.content.content) {
@@ -19,4 +22,44 @@ parser.tokenHandlers.html_block = function(state, tok, tokens, i) {
     }
 };
 
+// When we come across inline HTML we parse out the tag and keep track of
+// that in a stack, along with the marks they parse out to.
+// We open/close the marks within the state depending on the tag open/close type.
+const tagStack = new KeyedMultiStack();
+parser.tokenHandlers.html_inline = function(state, tok, tokens, i) {
+    const isClosing = tok.content.startsWith('</');
+    const isSelfClosing = tok.content.endsWith('/>');
+    const tagName = parseTagNameFromHtmlTokenContent(tok.content);
+
+    if (!isClosing) {
+        const completeTag = isSelfClosing ?  tok.content : `${tok.content}a</${tagName}>`;
+        const marks = extractMarksFromHtml(completeTag);
+        tagStack.push(tagName, marks);
+        for (const mark of marks) {
+            state.openMark(mark);
+        }
+    }
+
+    if (isSelfClosing || isClosing) {
+        const marks = (tagStack.pop(tagName) || []).reverse();
+        for (const mark of marks) {
+            state.closeMark(mark);
+        }
+    }
+}
+
+function extractMarksFromHtml(html) {
+    const contentDoc = htmlToDoc('<p>' + (html || '') + '</p>');
+    const marks = contentDoc?.content?.content?.[0]?.content?.content?.[0]?.marks;
+    return marks || [];
+}
+
+/**
+ * @param {string} tokenContent
+ * @return {string}
+ */
+function parseTagNameFromHtmlTokenContent(tokenContent) {
+    return tokenContent.split(' ')[0].replace(/[<>\/]/g, '').toLowerCase();
+}
+
 export default parser;
\ No newline at end of file
index 8e1e2b816c4f39474f9c04d4db920feb0c4900ad..a7c5d7d8233523fd289de09286a9210fae74bf15 100644 (file)
@@ -5,10 +5,20 @@ const nodes = defaultMarkdownSerializer.nodes;
 const marks = defaultMarkdownSerializer.marks;
 
 nodes.callout = function(state, node) {
+    writeNodeAsHtml(state, node);
+};
+
+marks.underline = {
+    open: '<span style="text-decoration: underline;">',
+    close: '</span>',
+};
+
+function writeNodeAsHtml(state, node) {
     const html = docToHtml({ content: [node] });
     state.write(html);
     state.closeBlock();
-};
+}
+
 
 const serializer = new MarkdownSerializer(nodes, marks);
 
index 1bdc718dc728595ca34e6673a531b455361cb69c..591878f7c6939b780763e1bf06ea47311de527cf 100644 (file)
@@ -1,10 +1,3 @@
-/**
- * Much of this code originates from https://github.com/ProseMirror/prosemirror-menu
- * and is hence subject to the MIT license found here:
- * https://github.com/ProseMirror/prosemirror-menu/blob/master/LICENSE
- * @copyright Marijn Haverbeke and others
- */
-
 import {
     MenuItem, Dropdown, DropdownSubmenu, renderGrouped, icons, joinUpItem, liftItem, selectParentNodeItem,
     undoItem, redoItem, wrapItem, blockTypeItem
@@ -62,6 +55,7 @@ function markItem(markType, options) {
 const inlineStyles = [
     markItem(schema.marks.strong, {title: "Bold", icon: icons.strong}),
     markItem(schema.marks.em, {title: "Italic", icon: icons.em}),
+    markItem(schema.marks.underline, {title: "Underline", label: 'U'}),
 ];
 
 const formats = [
@@ -109,9 +103,8 @@ const menu = menuBar({
     floating: false,
     content: [
         [undoItem, redoItem],
-        inlineStyles,
         [new DropdownSubmenu(formats, { label: 'Formats' })],
-
+        inlineStyles,
     ],
 });
 
index 53a08af1f38dbca6218b048e775556bf9ad6d16c..fb6192f228cb3398f38991842511d2dfd3393931 100644 (file)
@@ -3,6 +3,7 @@ import {schema as basicSchema} from "prosemirror-schema-basic";
 import {addListNodes} from "prosemirror-schema-list";
 
 const baseNodes = addListNodes(basicSchema.spec.nodes, "paragraph block*", "block");
+const baseMarks = basicSchema.spec.marks;
 
 const nodeCallout = {
     attrs: {
@@ -18,19 +19,30 @@ const nodeCallout = {
         {tag: 'p.callout.warning', attrs: {type: 'warning'}, priority: 75,},
         {tag: 'p.callout', attrs: {type: 'info'}, priority: 75},
     ],
-    toDOM: function(node) {
+    toDOM(node) {
         const type = node.attrs.type || 'info';
         return ['p', {class: 'callout ' + type}, 0];
     }
 };
 
+const markUnderline = {
+    parseDOM: [{tag: "u"}, {style: "text-decoration=underline"}],
+    toDOM() {
+        return ["span", {style: "text-decoration: underline;"}, 0];
+    }
+}
+
 const customNodes = baseNodes.append({
     callout: nodeCallout,
 });
 
+const customMarks = baseMarks.append({
+    underline: markUnderline,
+});
+
 const schema = new Schema({
     nodes: customNodes,
-    marks: basicSchema.spec.marks
+    marks: customMarks,
 })
 
 export default schema;
\ No newline at end of file
index ec940bd2b9c7d64d4a763389163f9da8445062b7..3c9cffde5085d12ca894d328c29993bfa6e85b2c 100644 (file)
@@ -13,4 +13,39 @@ export function docToHtml(doc) {
     const renderDoc = document.implementation.createHTMLDocument();
     renderDoc.body.appendChild(fragment);
     return renderDoc.body.innerHTML;
+}
+
+/**
+ * @class KeyedMultiStack
+ * Holds many stacks, seperated via a key, with a simple
+ * interface to pop and push values to the stacks.
+ */
+export class KeyedMultiStack {
+
+    constructor() {
+        this.stack = {};
+    }
+
+    /**
+     * @param {String} key
+     * @return {undefined|*}
+     */
+    pop(key) {
+        if (Array.isArray(this.stack[key])) {
+            return this.stack[key].pop();
+        }
+        return undefined;
+    }
+
+    /**
+     * @param {String} key
+     * @param {*} value
+     */
+    push(key, value) {
+        if (this.stack[key] === undefined) {
+            this.stack[key] = [];
+        }
+
+        this.stack[key].push(value);
+    }
 }
\ No newline at end of file
index bba8c3eca639868f6b5c50c98924c1c00538edcd..bba27f15321ed938b810514dcbc01c5fcb5a1a62 100644 (file)
 
         <div id="content" style="display: none;">
             <h2>This is an editable block</h2>
-            <p>Lorem ipsum dolor sit amet, <strong>consectetur adipisicing</strong> elit. Asperiores?</p>
+            <p>
+                Lorem ipsum dolor sit amet, <strong>consectetur adipisicing</strong> elit. Asperiores? <br>
+                Some <span style="text-decoration: underline">Underlined content</span> Lorem ipsum dolor sit amet.
+            </p>
             <p><img src="/user_avatar.png" alt="Logo"></p>
             <ul>
                 <li>Item A</li>