Got underline working in editor

Major step, since this is the first inline HTML element which needed
advanced parsing out on the markdown side, since not commonmark
supported.
This commit is contained in:
Dan Brown 2022-01-10 13:38:32 +00:00
parent 9d7174557e
commit a8f48185b5
No known key found for this signature in database
GPG Key ID: 46D9F943C24A2EF9
8 changed files with 116 additions and 18 deletions

View 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",

View 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() {

View 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;

View 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);

View 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,
],
});

View 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;

View 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);
}
}

View File

@ -11,7 +11,10 @@
<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>