diff --git a/package.json b/package.json index fdf65f63b..c4ea1a302 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/resources/js/editor/MarkdownView.js b/resources/js/editor/MarkdownView.js index f952c8606..88fc1fff5 100644 --- a/resources/js/editor/MarkdownView.js +++ b/resources/js/editor/MarkdownView.js @@ -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() { diff --git a/resources/js/editor/markdown-parser.js b/resources/js/editor/markdown-parser.js index 46495a7e0..b198ffad4 100644 --- a/resources/js/editor/markdown-parser.js +++ b/resources/js/editor/markdown-parser.js @@ -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 tagName = parseTagNameFromHtmlTokenContent(tok.content); + + if (!isClosing) { + const completeTag = isSelfClosing ? tok.content : `${tok.content}a`; + 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('

' + (html || '') + '

'); + 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 diff --git a/resources/js/editor/markdown-serializer.js b/resources/js/editor/markdown-serializer.js index 8e1e2b816..a7c5d7d82 100644 --- a/resources/js/editor/markdown-serializer.js +++ b/resources/js/editor/markdown-serializer.js @@ -5,10 +5,20 @@ const nodes = defaultMarkdownSerializer.nodes; const marks = defaultMarkdownSerializer.marks; nodes.callout = function(state, node) { + writeNodeAsHtml(state, node); +}; + +marks.underline = { + open: '', + close: '', +}; + +function writeNodeAsHtml(state, node) { const html = docToHtml({ content: [node] }); state.write(html); state.closeBlock(); -}; +} + const serializer = new MarkdownSerializer(nodes, marks); diff --git a/resources/js/editor/menu/index.js b/resources/js/editor/menu/index.js index 1bdc718dc..591878f7c 100644 --- a/resources/js/editor/menu/index.js +++ b/resources/js/editor/menu/index.js @@ -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, ], }); diff --git a/resources/js/editor/schema.js b/resources/js/editor/schema.js index 53a08af1f..fb6192f22 100644 --- a/resources/js/editor/schema.js +++ b/resources/js/editor/schema.js @@ -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 diff --git a/resources/js/editor/util.js b/resources/js/editor/util.js index ec940bd2b..3c9cffde5 100644 --- a/resources/js/editor/util.js +++ b/resources/js/editor/util.js @@ -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 diff --git a/resources/views/editor-test.blade.php b/resources/views/editor-test.blade.php index bba8c3eca..bba27f153 100644 --- a/resources/views/editor-test.blade.php +++ b/resources/views/editor-test.blade.php @@ -11,7 +11,10 @@