diff --git a/resources/js/editor.js b/resources/js/editor.js index 6b4aff1bc..11e908834 100644 --- a/resources/js/editor.js +++ b/resources/js/editor.js @@ -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 index 000000000..30e92713e --- /dev/null +++ b/resources/js/editor/MarkdownView.js @@ -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(`

\n`); + state.text(node.textContent, false); + state.ensureNewLine(); + state.write(`

`); + 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 index 000000000..1988d6921 --- /dev/null +++ b/resources/js/editor/ProseMirrorView.js @@ -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 diff --git a/resources/js/editor/schema.js b/resources/js/editor/schema.js index 540db5704..7c6a11cb9 100644 --- a/resources/js/editor/schema.js +++ b/resources/js/editor/schema.js @@ -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 diff --git a/resources/views/editor-test.blade.php b/resources/views/editor-test.blade.php index dd740ab0a..ee60a2dd3 100644 --- a/resources/views/editor-test.blade.php +++ b/resources/views/editor-test.blade.php @@ -23,6 +23,9 @@
  • Item C
  • Lorem ipsum dolor sit amet.

    +

    + This is an info callout test! +