]> BookStack Code Mirror - bookstack/blob - resources/js/editor/MarkdownView.js
Attempted adding tricky custom block
[bookstack] / resources / js / editor / MarkdownView.js
1 import schema from "./schema";
2 import {MarkdownSerializer, MarkdownParser} from "prosemirror-markdown";
3 import {DOMParser, DOMSerializer} from "prosemirror-model";
4 import markdownit from "markdown-it";
5
6
7 function listIsTight(tokens, i) {
8     while (++i < tokens.length)
9     { if (tokens[i].type != "list_item_open") { return tokens[i].hidden } }
10     return false
11 }
12
13 // TODO - Need to tweak parser logic
14 //  so HTML blocks get parsed out using the normal DOMParser logic.
15 //  Likely need to copy & alter the inner parsing logic
16
17 const mdParser = new MarkdownParser(schema, markdownit("commonmark", {html: true}), {
18     blockquote: {block: "blockquote"},
19     paragraph: {block: "paragraph"},
20     html_block: { block: "callout", noCloseToken: true, getAttrs: function(tok) {
21         return {
22             type: 'info',
23         }
24     }},
25     list_item: {block: "list_item"},
26     bullet_list: {block: "bullet_list", getAttrs: function (_, tokens, i) { return ({tight: listIsTight(tokens, i)}); }},
27     ordered_list: {block: "ordered_list", getAttrs: function (tok, tokens, i) { return ({
28             order: +tok.attrGet("start") || 1,
29             tight: listIsTight(tokens, i)
30         }); }},
31     heading: {block: "heading", getAttrs: function (tok) { return ({level: +tok.tag.slice(1)}); }},
32     code_block: {block: "code_block", noCloseToken: true},
33     fence: {block: "code_block", getAttrs: function (tok) { return ({params: tok.info || ""}); }, noCloseToken: true},
34     hr: {node: "horizontal_rule"},
35     image: {node: "image", getAttrs: function (tok) { return ({
36             src: tok.attrGet("src"),
37             title: tok.attrGet("title") || null,
38             alt: tok.children[0] && tok.children[0].content || null
39         }); }},
40     hardbreak: {node: "hard_break"},
41
42     em: {mark: "em"},
43     strong: {mark: "strong"},
44     link: {mark: "link", getAttrs: function (tok) { return ({
45             href: tok.attrGet("href"),
46             title: tok.attrGet("title") || null
47         }); }},
48     code_inline: {mark: "code", noCloseToken: true}
49 });
50
51 const mdSerializer = new MarkdownSerializer({
52     blockquote: function blockquote(state, node) {
53         state.wrapBlock("> ", null, node, function () { return state.renderContent(node); });
54     },
55     callout: function(state, node) {
56         state.write(`<p class="callout ${node.attrs.type}">\n`);
57         state.text(node.textContent, false);
58         state.ensureNewLine();
59         state.write(`</p>`);
60         state.closeBlock(node);
61     },
62     code_block: function code_block(state, node) {
63         state.write("```" + (node.attrs.params || "") + "\n");
64         state.text(node.textContent, false);
65         state.ensureNewLine();
66         state.write("```");
67         state.closeBlock(node);
68     },
69     heading: function heading(state, node) {
70         state.write(state.repeat("#", node.attrs.level) + " ");
71         state.renderInline(node);
72         state.closeBlock(node);
73     },
74     horizontal_rule: function horizontal_rule(state, node) {
75         state.write(node.attrs.markup || "---");
76         state.closeBlock(node);
77     },
78     bullet_list: function bullet_list(state, node) {
79         state.renderList(node, "  ", function () { return (node.attrs.bullet || "*") + " "; });
80     },
81     ordered_list: function ordered_list(state, node) {
82         var start = node.attrs.order || 1;
83         var maxW = String(start + node.childCount - 1).length;
84         var space = state.repeat(" ", maxW + 2);
85         state.renderList(node, space, function (i) {
86             var nStr = String(start + i);
87             return state.repeat(" ", maxW - nStr.length) + nStr + ". "
88         });
89     },
90     list_item: function list_item(state, node) {
91         state.renderContent(node);
92     },
93     paragraph: function paragraph(state, node) {
94         state.renderInline(node);
95         state.closeBlock(node);
96     },
97
98     image: function image(state, node) {
99         state.write("![" + state.esc(node.attrs.alt || "") + "](" + state.esc(node.attrs.src) +
100             (node.attrs.title ? " " + state.quote(node.attrs.title) : "") + ")");
101     },
102     hard_break: function hard_break(state, node, parent, index) {
103         for (var i = index + 1; i < parent.childCount; i++)
104         { if (parent.child(i).type != node.type) {
105             state.write("\\\n");
106             return
107         } }
108     },
109     text: function text(state, node) {
110         state.text(node.text);
111     }
112 }, {
113     em: {open: "*", close: "*", mixable: true, expelEnclosingWhitespace: true},
114     strong: {open: "**", close: "**", mixable: true, expelEnclosingWhitespace: true},
115     link: {
116         open: function open(_state, mark, parent, index) {
117             return isPlainURL(mark, parent, index, 1) ? "<" : "["
118         },
119         close: function close(state, mark, parent, index) {
120             return isPlainURL(mark, parent, index, -1) ? ">"
121                 : "](" + state.esc(mark.attrs.href) + (mark.attrs.title ? " " + state.quote(mark.attrs.title) : "") + ")"
122         }
123     },
124     code: {open: function open(_state, _mark, parent, index) { return backticksFor(parent.child(index), -1) },
125         close: function close(_state, _mark, parent, index) { return backticksFor(parent.child(index - 1), 1) },
126         escape: false}
127 });
128
129 function backticksFor(node, side) {
130     var ticks = /`+/g, m, len = 0;
131     if (node.isText) { while (m = ticks.exec(node.text)) { len = Math.max(len, m[0].length); } }
132     var result = len > 0 && side > 0 ? " `" : "`";
133     for (var i = 0; i < len; i++) { result += "`"; }
134     if (len > 0 && side < 0) { result += " "; }
135     return result
136 }
137
138 function isPlainURL(link, parent, index, side) {
139     if (link.attrs.title || !/^\w+:/.test(link.attrs.href)) { return false }
140     var content = parent.child(index + (side < 0 ? -1 : 0));
141     if (!content.isText || content.text != link.attrs.href || content.marks[content.marks.length - 1] != link) { return false }
142     if (index == (side < 0 ? 1 : parent.childCount - 1)) { return true }
143     var next = parent.child(index + (side < 0 ? -2 : 1));
144     return !link.isInSet(next.marks)
145 }
146
147 class MarkdownView {
148     constructor(target, content) {
149
150         // Build DOM from content
151         const renderDoc = document.implementation.createHTMLDocument();
152         renderDoc.body.innerHTML = content;
153
154         const htmlDoc = DOMParser.fromSchema(schema).parse(renderDoc.body);
155         const markdown = mdSerializer.serialize(htmlDoc);
156
157         this.textarea = target.appendChild(document.createElement("textarea"))
158         this.textarea.value = markdown;
159     }
160
161     get content() {
162         const markdown = this.textarea.value;
163         const doc = mdParser.parse(markdown);
164         console.log(doc);
165         const fragment = DOMSerializer.fromSchema(schema).serializeFragment(doc.content);
166         const renderDoc = document.implementation.createHTMLDocument();
167         renderDoc.body.appendChild(fragment);
168         return renderDoc.body.innerHTML;
169     }
170
171     focus() { this.textarea.focus() }
172     destroy() { this.textarea.remove() }
173 }
174
175 export default MarkdownView;