]> BookStack Code Mirror - bookstack/commitdiff
Attempted adding tricky custom block
authorDan Brown <redacted>
Fri, 7 Jan 2022 16:37:36 +0000 (16:37 +0000)
committerDan Brown <redacted>
Fri, 7 Jan 2022 16:37:36 +0000 (16:37 +0000)
Attempted adding callouts, which have the challenge of being shown via
HTML within markdown content. Got stuck on parsing back to the state
from markdown.

resources/js/editor.js
resources/js/editor/MarkdownView.js [new file with mode: 0644]
resources/js/editor/ProseMirrorView.js [new file with mode: 0644]
resources/js/editor/schema.js
resources/views/editor-test.blade.php

index 6b4aff1bc1c33e4ef01d28b393d93830c1deee9c..11e908834e5e32a4aaa6918c0b19187ba991731e 100644 (file)
@@ -1,63 +1,6 @@
-import {EditorState} from "prosemirror-state";
-import {EditorView} from "prosemirror-view";
-import {exampleSetup} from "prosemirror-example-setup";
-import {defaultMarkdownParser,
-    defaultMarkdownSerializer} from "prosemirror-markdown";
-import {DOMParser, DOMSerializer} from "prosemirror-model";
+import MarkdownView from "./editor/MarkdownView";
+import ProseMirrorView from "./editor/ProseMirrorView";
 
-import {schema} from "./editor/schema";
-
-class MarkdownView {
-    constructor(target, content) {
-
-        // Build DOM from content
-        const renderDoc = document.implementation.createHTMLDocument();
-        renderDoc.body.innerHTML = content;
-
-        const htmlDoc = DOMParser.fromSchema(schema).parse(renderDoc.body);
-        const markdown = defaultMarkdownSerializer.serialize(htmlDoc);
-
-        this.textarea = target.appendChild(document.createElement("textarea"))
-        this.textarea.value = markdown;
-    }
-
-    get content() {
-        const markdown = this.textarea.value;
-        const doc = defaultMarkdownParser.parse(markdown);
-        const fragment = DOMSerializer.fromSchema(schema).serializeFragment(doc.content);
-        const renderDoc = document.implementation.createHTMLDocument();
-        renderDoc.body.appendChild(fragment);
-        return renderDoc.body.innerHTML;
-    }
-
-    focus() { this.textarea.focus() }
-    destroy() { this.textarea.remove() }
-}
-
-class ProseMirrorView {
-    constructor(target, content) {
-
-        // Build DOM from content
-        const renderDoc = document.implementation.createHTMLDocument();
-        renderDoc.body.innerHTML = content;
-
-        this.view = new EditorView(target, {
-            state: EditorState.create({
-                doc: DOMParser.fromSchema(schema).parse(renderDoc.body),
-                plugins: exampleSetup({schema})
-            })
-        });
-    }
-
-    get content() {
-        const fragment = DOMSerializer.fromSchema(schema).serializeFragment(this.view.state.doc.content);
-        const renderDoc = document.implementation.createHTMLDocument();
-        renderDoc.body.appendChild(fragment);
-        return renderDoc.body.innerHTML;
-    }
-    focus() { this.view.focus() }
-    destroy() { this.view.destroy() }
-}
 
 const place = document.querySelector("#editor");
 let view = new ProseMirrorView(place, document.getElementById('content').innerHTML);
diff --git a/resources/js/editor/MarkdownView.js b/resources/js/editor/MarkdownView.js
new file mode 100644 (file)
index 0000000..30e9271
--- /dev/null
@@ -0,0 +1,175 @@
+import schema from "./schema";
+import {MarkdownSerializer, MarkdownParser} from "prosemirror-markdown";
+import {DOMParser, DOMSerializer} from "prosemirror-model";
+import markdownit from "markdown-it";
+
+
+function listIsTight(tokens, i) {
+    while (++i < tokens.length)
+    { if (tokens[i].type != "list_item_open") { return tokens[i].hidden } }
+    return false
+}
+
+// TODO - Need to tweak parser logic
+//  so HTML blocks get parsed out using the normal DOMParser logic.
+//  Likely need to copy & alter the inner parsing logic
+
+const mdParser = new MarkdownParser(schema, markdownit("commonmark", {html: true}), {
+    blockquote: {block: "blockquote"},
+    paragraph: {block: "paragraph"},
+    html_block: { block: "callout", noCloseToken: true, getAttrs: function(tok) {
+        return {
+            type: 'info',
+        }
+    }},
+    list_item: {block: "list_item"},
+    bullet_list: {block: "bullet_list", getAttrs: function (_, tokens, i) { return ({tight: listIsTight(tokens, i)}); }},
+    ordered_list: {block: "ordered_list", getAttrs: function (tok, tokens, i) { return ({
+            order: +tok.attrGet("start") || 1,
+            tight: listIsTight(tokens, i)
+        }); }},
+    heading: {block: "heading", getAttrs: function (tok) { return ({level: +tok.tag.slice(1)}); }},
+    code_block: {block: "code_block", noCloseToken: true},
+    fence: {block: "code_block", getAttrs: function (tok) { return ({params: tok.info || ""}); }, noCloseToken: true},
+    hr: {node: "horizontal_rule"},
+    image: {node: "image", getAttrs: function (tok) { return ({
+            src: tok.attrGet("src"),
+            title: tok.attrGet("title") || null,
+            alt: tok.children[0] && tok.children[0].content || null
+        }); }},
+    hardbreak: {node: "hard_break"},
+
+    em: {mark: "em"},
+    strong: {mark: "strong"},
+    link: {mark: "link", getAttrs: function (tok) { return ({
+            href: tok.attrGet("href"),
+            title: tok.attrGet("title") || null
+        }); }},
+    code_inline: {mark: "code", noCloseToken: true}
+});
+
+const mdSerializer = new MarkdownSerializer({
+    blockquote: function blockquote(state, node) {
+        state.wrapBlock("> ", null, node, function () { return state.renderContent(node); });
+    },
+    callout: function(state, node) {
+        state.write(`<p class="callout ${node.attrs.type}">\n`);
+        state.text(node.textContent, false);
+        state.ensureNewLine();
+        state.write(`</p>`);
+        state.closeBlock(node);
+    },
+    code_block: function code_block(state, node) {
+        state.write("```" + (node.attrs.params || "") + "\n");
+        state.text(node.textContent, false);
+        state.ensureNewLine();
+        state.write("```");
+        state.closeBlock(node);
+    },
+    heading: function heading(state, node) {
+        state.write(state.repeat("#", node.attrs.level) + " ");
+        state.renderInline(node);
+        state.closeBlock(node);
+    },
+    horizontal_rule: function horizontal_rule(state, node) {
+        state.write(node.attrs.markup || "---");
+        state.closeBlock(node);
+    },
+    bullet_list: function bullet_list(state, node) {
+        state.renderList(node, "  ", function () { return (node.attrs.bullet || "*") + " "; });
+    },
+    ordered_list: function ordered_list(state, node) {
+        var start = node.attrs.order || 1;
+        var maxW = String(start + node.childCount - 1).length;
+        var space = state.repeat(" ", maxW + 2);
+        state.renderList(node, space, function (i) {
+            var nStr = String(start + i);
+            return state.repeat(" ", maxW - nStr.length) + nStr + ". "
+        });
+    },
+    list_item: function list_item(state, node) {
+        state.renderContent(node);
+    },
+    paragraph: function paragraph(state, node) {
+        state.renderInline(node);
+        state.closeBlock(node);
+    },
+
+    image: function image(state, node) {
+        state.write("![" + state.esc(node.attrs.alt || "") + "](" + state.esc(node.attrs.src) +
+            (node.attrs.title ? " " + state.quote(node.attrs.title) : "") + ")");
+    },
+    hard_break: function hard_break(state, node, parent, index) {
+        for (var i = index + 1; i < parent.childCount; i++)
+        { if (parent.child(i).type != node.type) {
+            state.write("\\\n");
+            return
+        } }
+    },
+    text: function text(state, node) {
+        state.text(node.text);
+    }
+}, {
+    em: {open: "*", close: "*", mixable: true, expelEnclosingWhitespace: true},
+    strong: {open: "**", close: "**", mixable: true, expelEnclosingWhitespace: true},
+    link: {
+        open: function open(_state, mark, parent, index) {
+            return isPlainURL(mark, parent, index, 1) ? "<" : "["
+        },
+        close: function close(state, mark, parent, index) {
+            return isPlainURL(mark, parent, index, -1) ? ">"
+                : "](" + state.esc(mark.attrs.href) + (mark.attrs.title ? " " + state.quote(mark.attrs.title) : "") + ")"
+        }
+    },
+    code: {open: function open(_state, _mark, parent, index) { return backticksFor(parent.child(index), -1) },
+        close: function close(_state, _mark, parent, index) { return backticksFor(parent.child(index - 1), 1) },
+        escape: false}
+});
+
+function backticksFor(node, side) {
+    var ticks = /`+/g, m, len = 0;
+    if (node.isText) { while (m = ticks.exec(node.text)) { len = Math.max(len, m[0].length); } }
+    var result = len > 0 && side > 0 ? " `" : "`";
+    for (var i = 0; i < len; i++) { result += "`"; }
+    if (len > 0 && side < 0) { result += " "; }
+    return result
+}
+
+function isPlainURL(link, parent, index, side) {
+    if (link.attrs.title || !/^\w+:/.test(link.attrs.href)) { return false }
+    var content = parent.child(index + (side < 0 ? -1 : 0));
+    if (!content.isText || content.text != link.attrs.href || content.marks[content.marks.length - 1] != link) { return false }
+    if (index == (side < 0 ? 1 : parent.childCount - 1)) { return true }
+    var next = parent.child(index + (side < 0 ? -2 : 1));
+    return !link.isInSet(next.marks)
+}
+
+class MarkdownView {
+    constructor(target, content) {
+
+        // Build DOM from content
+        const renderDoc = document.implementation.createHTMLDocument();
+        renderDoc.body.innerHTML = content;
+
+        const htmlDoc = DOMParser.fromSchema(schema).parse(renderDoc.body);
+        const markdown = mdSerializer.serialize(htmlDoc);
+
+        this.textarea = target.appendChild(document.createElement("textarea"))
+        this.textarea.value = markdown;
+    }
+
+    get content() {
+        const markdown = this.textarea.value;
+        const doc = mdParser.parse(markdown);
+        console.log(doc);
+        const fragment = DOMSerializer.fromSchema(schema).serializeFragment(doc.content);
+        const renderDoc = document.implementation.createHTMLDocument();
+        renderDoc.body.appendChild(fragment);
+        return renderDoc.body.innerHTML;
+    }
+
+    focus() { this.textarea.focus() }
+    destroy() { this.textarea.remove() }
+}
+
+export default MarkdownView;
\ No newline at end of file
diff --git a/resources/js/editor/ProseMirrorView.js b/resources/js/editor/ProseMirrorView.js
new file mode 100644 (file)
index 0000000..1988d69
--- /dev/null
@@ -0,0 +1,34 @@
+import {EditorState} from "prosemirror-state";
+import {EditorView} from "prosemirror-view";
+import {exampleSetup} from "prosemirror-example-setup";
+
+import {DOMParser, DOMSerializer} from "prosemirror-model";
+
+import schema from "./schema";
+
+class ProseMirrorView {
+    constructor(target, content) {
+
+        // Build DOM from content
+        const renderDoc = document.implementation.createHTMLDocument();
+        renderDoc.body.innerHTML = content;
+
+        this.view = new EditorView(target, {
+            state: EditorState.create({
+                doc: DOMParser.fromSchema(schema).parse(renderDoc.body),
+                plugins: exampleSetup({schema})
+            })
+        });
+    }
+
+    get content() {
+        const fragment = DOMSerializer.fromSchema(schema).serializeFragment(this.view.state.doc.content);
+        const renderDoc = document.implementation.createHTMLDocument();
+        renderDoc.body.appendChild(fragment);
+        return renderDoc.body.innerHTML;
+    }
+    focus() { this.view.focus() }
+    destroy() { this.view.destroy() }
+}
+
+export default ProseMirrorView;
\ No newline at end of file
index 540db5704822506c583e4e0e8eab47666eadc495..7c6a11cb9db1959b9a60612b282f3df1dab4cf54 100644 (file)
@@ -2,9 +2,35 @@ import {Schema} from "prosemirror-model";
 import {schema as basicSchema} from "prosemirror-schema-basic";
 import {addListNodes} from "prosemirror-schema-list";
 
-const bookstackSchema = new Schema({
-    nodes: addListNodes(basicSchema.spec.nodes, "paragraph block*", "block"),
+const baseNodes = addListNodes(basicSchema.spec.nodes, "paragraph block*", "block");
+
+const nodeCallout = {
+    attrs: {
+        type: {default: 'info'},
+    },
+    content: "inline*",
+    group: "block",
+    defining: true,
+    parseDOM: [
+        {tag: 'p.callout.info', attrs: {type: 'info'}},
+        {tag: 'p.callout.success', attrs: {type: 'success'}},
+        {tag: 'p.callout.danger', attrs: {type: 'danger'}},
+        {tag: 'p.callout.warning', attrs: {type: 'warning'}},
+        {tag: 'p.callout', attrs: {type: 'info'}},
+    ],
+    toDOM: function(node) {
+        const type = node.attrs.type || 'info';
+        return ['p', {class: 'callout ' + type}, 0];
+    }
+};
+
+const customNodes = baseNodes.prepend({
+    callout: nodeCallout,
+});
+
+const schema = new Schema({
+    nodes: customNodes,
     marks: basicSchema.spec.marks
 })
 
-export {bookstackSchema as schema};
\ No newline at end of file
+export default schema;
\ No newline at end of file
index dd740ab0ad030ae8c032e528d049f538a660fef5..ee60a2dd3aac78b52ce0e1822f85a6dac1b635db 100644 (file)
@@ -23,6 +23,9 @@
                 <li>Item C</li>
             </ul>
             <p>Lorem ipsum dolor sit amet.</p>
+            <p class="callout info">
+                This is an info callout test!
+            </p>
         </div>
 
     </div>