diff --git a/css/markdown-editor.css b/css/markdown-editor.css index b2d35f6..28e8e5c 100644 --- a/css/markdown-editor.css +++ b/css/markdown-editor.css @@ -85,6 +85,28 @@ .EasyMDEContainer .editor-toolbar button i.fa-undo::before { content: "\21B6"; } .EasyMDEContainer .editor-toolbar button i.fa-repeat::before { content: "\21B7"; } .EasyMDEContainer .editor-toolbar button i.fa-eraser::before { content: "\232B"; } +.EasyMDEContainer .editor-toolbar button i.fa-underline::before { content: "U"; text-decoration: underline; } +.EasyMDEContainer .editor-toolbar button i.fa-font::before { content: "A"; font-weight: bold; } + +/* + * The colour menu: one row per colour, its "A" in that colour (set by + * markdown-editor.js from the website's palette) next to its name. + */ +.EasyMDEContainer .editor-toolbar .easymde-dropdown-content { + background-color: #fff; + border: 1px solid #c7c3bf; + padding: 2px 0; + z-index: 10; +} + +.EasyMDEContainer .editor-toolbar .easymde-dropdown-content button { + display: block; + width: 100%; + text-align: left; + white-space: nowrap; + color: #333; + font-weight: normal; +} /* ---- Rendered Markdown ---- */ diff --git a/js/markdown-editor.js b/js/markdown-editor.js index b512c20..cf3af26 100644 --- a/js/markdown-editor.js +++ b/js/markdown-editor.js @@ -8,22 +8,950 @@ * first. Both are served from our own domain: EasyMDE's own CDN fetch for the * toolbar icon font is switched off, and css/markdown-editor.css labels the * toolbar buttons instead. + * + * Colour and underline: Markdown has neither, so the website keeps them as + * with a class from a fixed list, and renders only those + * spans, and only when a span opens and closes inside one block without + * crossing other formatting; one that does not is left off the page. The + * textarea's data-markdown-styles attribute carries the list + * (App\Form\Type\MarkdownEditorType), so the buttons offer exactly what the + * site renders and this script keeps no copy of it. Without the attribute + * there are no such buttons. + * + * So that a style an author adds also shows, no edit is made on trust. The + * buttons work line by line, leave code, tables and other block syntax alone, + * and try every edit first: the paragraph around the line is rendered with a + * probe span and without it, and the edit is made only if the probe opens and + * closes around properly nested content with no block inside, and taking it + * out again gives back exactly the rendering the paragraph had without it. + * When the selected part of a line does not pass, the whole line's text is + * tried; when that does not pass either, the line is left as it is. */ (function () { 'use strict'; - var TOOLBAR = [ - 'bold', 'italic', 'heading', - '|', - 'quote', 'unordered-list', 'ordered-list', - '|', - 'link', 'image', 'table', 'code', - '|', - 'preview', 'side-by-side', 'fullscreen', - '|', - 'guide' + var CLOSE = ''; + + var PROBE = 'markdown-editor-probe'; + + /** Tags a style span may not contain; the site's rule too. */ + var BLOCK_TAGS = ['p', 'li', 'ul', 'ol', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'table', 'thead', 'tbody', 'tr', 'td', 'th', 'div', 'hr', 'pre']; + + var VOID_TAGS = ['br', 'img', 'input', 'wbr']; + + /** + * List, heading and quote markers at the start of a line. A span has to + * start after them, or the line stops being a list item, heading or quote. + */ + var BLOCK_PREFIX = /^(\s*(?:(?:[*+-]|\d+[.)])\s+|#{1,6}\s+|>\s?)*)/; + + /** A code fence, after any quote or list markers. */ + var FENCE = /^\s*(`{3,}|~{3,})/; + + /** A link reference definition. */ + var REFERENCE = /^ {0,3}\[[^\]]+\]:/; + + /** Lines whose syntax a span would break, styled or not. */ + var UNTOUCHABLE_LINE = [ + FENCE, + /^ {0,3}([-*_])( *\1){2,} *$/, // thematic break + /^ {0,3}(=+|-+) *$/, // setext heading underline + REFERENCE ]; + var SPAN_TAG = /]*)">|<\/span>/g; + + /** Emphasis and code markers a selection may sit just inside of. */ + var MARKERS = '*_~`'; + + function escapeHtml(text) { + return String(text).replace(/[&<>"']/g, function (character) { + return '&#' + character.charCodeAt(0) + ';'; + }); + } + + function openTag(className) { + return ''; + } + + function comparePositions(a, b) { + return a.line === b.line ? a.ch - b.ch : a.line - b.line; + } + + function isBlank(text) { + return /^\s*$/.test(text); + } + + function withoutPrefix(text) { + return text.slice(text.match(BLOCK_PREFIX)[1].length); + } + + /** + * Markdown rendered by EasyMDE's own renderer: the raw HTML marked wrote, + * captured before the browser could repair any bad nesting in it, and the + * result as a detached document, in which nothing runs and nothing loads. + */ + function render(editor, markdown) { + var html = editor.markdown(markdown); + + return { + raw: editor.markdownCapture.raw, + body: new DOMParser().parseFromString(html, 'text/html').body + }; + } + + /** + * Whether the probe span in marked's raw HTML closes around properly + * nested inline content: every tag opened inside it closes inside it, no + * block starts inside it, and it is not itself closed early. + */ + function probeNestsCleanly(raw) { + var start = raw.indexOf(openTag(PROBE)); + + if (start === -1) { + return false; + } + + var tag = /<(\/?)([a-zA-Z][a-zA-Z0-9]*)\b[^>]*>/g; + var stack = []; + var match; + + tag.lastIndex = start + openTag(PROBE).length; + + while ((match = tag.exec(raw)) !== null) { + var name = match[2].toLowerCase(); + + if (BLOCK_TAGS.indexOf(name) !== -1) { + return false; + } + + if (VOID_TAGS.indexOf(name) !== -1) { + continue; + } + + if (match[1] === '') { + stack.push(name); + } else if (stack.length === 0) { + return name === 'span'; + } else if (stack.pop() !== name) { + return false; + } + } + + return false; + } + + /** + * The inline code spans of a paragraph as [start, end) offsets, by + * CommonMark's rule, which is the site's: a backtick run opens one, and + * the next run of exactly the same length closes it. A run with no such + * partner is plain text. A run after a backslash opens nothing. + */ + function codeSpans(text) { + var runs = []; + var spans = []; + var match; + var run = /`+/g; + + while ((match = run.exec(text)) !== null) { + var backslashes = 0; + + for (var b = match.index - 1; b >= 0 && text.charAt(b) === '\\'; b--) { + backslashes++; + } + + runs.push({ index: match.index, length: match[0].length, escaped: backslashes % 2 === 1 }); + } + + for (var i = 0; i < runs.length; i++) { + if (runs[i].escaped) { + continue; + } + + for (var j = i + 1; j < runs.length; j++) { + if (runs[j].length === runs[i].length) { + spans.push([runs[i].index, runs[j].index + runs[j].length]); + i = j; + break; + } + } + } + + return spans; + } + + /** + * How many unescaped "[" are still open at a position in a text. + */ + function bracketDepth(text, position) { + var depth = 0; + + for (var i = 0; i < position; i++) { + var character = text.charAt(i); + + if (character === '\\') { + i++; + } else if (character === '[') { + depth++; + } else if (character === ']' && depth > 0) { + depth--; + } + } + + return depth; + } + + /** + * Whether every span tag in a text, of any class, has its partner in it. + */ + function spanTagsBalance(text) { + var depth = 0; + var match; + + SPAN_TAG.lastIndex = 0; + + while ((match = SPAN_TAG.exec(text)) !== null) { + depth += match[1] !== undefined ? 1 : -1; + + if (depth < 0) { + return false; + } + } + + return depth === 0; + } + + /** + * The paragraph a line belongs to, from the blank line before it to the + * blank line after, with the line replaced, and every link reference + * definition of the document after it, so links resolve as on the page. + */ + function paragraph(cm, context, line, text) { + return paragraphParts(cm, line, text).text + context.references; + } + + /** + * The paragraph around a line, with the line replaced, and where the line + * starts in it. + */ + function paragraphParts(cm, line, text) { + var first = line; + var last = line; + + // An indented paragraph continues the list item above it, across blank + // lines; alone it would read as a code block. + for (;;) { + while (first > 0 && !isBlank(cm.getLine(first - 1))) { + first--; + } + + if (first === 0 || !/^( {2,}|\t)/.test(cm.getLine(first))) { + break; + } + + while (first > 0 && isBlank(cm.getLine(first - 1))) { + first--; + } + + if (first === 0) { + break; + } + } + + while (last < cm.lineCount() - 1 && !isBlank(cm.getLine(last + 1))) { + last++; + } + + var lines = []; + var offset = 0; + + for (var i = first; i <= last; i++) { + if (i < line) { + offset += cm.getLine(i).length + 1; + } + + lines.push(i === line ? text : cm.getLine(i)); + } + + return { text: lines.join('\n'), offset: offset }; + } + + /** + * Whether wrapping body in a span, between before and after on one line, + * renders as intended and changes nothing else. + */ + function rendersCleanly(editor, context, line, before, body, after) { + var cm = editor.codemirror; + + // The span's own closing tag has to be the one that closes it: a + // body with span tags that do not pair up, such as an author's stray + // "", would close it early. + if (!spanTagsBalance(body)) { + return false; + } + + // Neither edge may fall inside inline code, by the site's rule: the + // two renderers can disagree on where odd code spans start and end. + var parts = paragraphParts(cm, line, before + body + after); + var start = parts.offset + before.length; + var end = start + body.length; + var spans = codeSpans(parts.text); + + // Nor split a run of backticks, which would change which runs pair up, + // nor follow a backslash, which would escape the tag's "<". + if ((parts.text.charAt(start - 1) === '`' && parts.text.charAt(start) === '`') + || (parts.text.charAt(end - 1) === '`' && parts.text.charAt(end) === '`') + || parts.text.charAt(start - 1) === '\\' + || parts.text.charAt(end - 1) === '\\' + ) { + return false; + } + + for (var s = 0; s < spans.length; s++) { + if ((start > spans[s][0] && start < spans[s][1]) || (end > spans[s][0] && end < spans[s][1])) { + return false; + } + } + + // Nor cross into or out of the brackets of a link or image: both edges + // at the same bracket depth. + if (bracketDepth(parts.text, start) !== bracketDepth(parts.text, end)) { + return false; + } + + var plain = render(editor, paragraph(cm, context, line, before + body + after)); + var probed = render(editor, paragraph(cm, context, line, before + openTag(PROBE) + body + CLOSE + after)); + + if (!probeNestsCleanly(probed.raw)) { + return false; + } + + var span = probed.body.querySelector('span.' + PROBE); + + if (!span) { + return false; + } + + while (span.firstChild) { + span.parentNode.insertBefore(span.firstChild, span); + } + + span.parentNode.removeChild(span); + + return probed.body.innerHTML === plain.body.innerHTML; + } + + /** + * Whether a line is code: part of a fenced or indented code block, in a + * quote or list item or not. EasyMDE's Markdown mode marks code as + * "comment"; quote and list markers are tokens of their own. + */ + function isCodeLine(cm, line) { + var tokens = cm.getLineTokens(line); + var code = false; + + for (var i = 0; i < tokens.length; i++) { + var type = tokens[i].type || ''; + + if (/^\s*$/.test(tokens[i].string) || (/\b(quote|list|formatting-quote|formatting-list)\b/.test(type) && !/\bcomment\b/.test(type))) { + continue; + } + + // Inline code is "comment" too, but has its backticks marked as + // such: a line that is only `code` is still a paragraph. + if (!/\bcomment\b/.test(type) || /\bformatting-code\b/.test(type)) { + return false; + } + + code = true; + } + + return code; + } + + /** + * Whether a line lies between the fences of a fenced code block. + */ + function isInsideFence(cm, line) { + var open = null; + + for (var i = 0; i < line; i++) { + var content = withoutPrefix(cm.getLine(i)); + var fence = content.match(/^\s{0,3}(`{3,}|~{3,})(.*)$/); + + if (!fence) { + continue; + } + + if (open === null) { + // A backtick fence's info string may not hold backticks: a line + // like ```x``` is inline code, not a fence. + if (fence[1].charAt(0) !== '`' || fence[2].indexOf('`') === -1) { + open = fence[1]; + } + } else if (fence[1].charAt(0) === open.charAt(0) && fence[1].length >= open.length && /^\s*$/.test(fence[2])) { + // Closed only by the same character, at least as many of it, + // and nothing after. + open = null; + } + } + + return open !== null; + } + + function isUntouchable(cm, line) { + var text = cm.getLine(line); + var content = withoutPrefix(text); + + for (var i = 0; i < UNTOUCHABLE_LINE.length; i++) { + if (UNTOUCHABLE_LINE[i].test(text) || UNTOUCHABLE_LINE[i].test(content)) { + return true; + } + } + + // The URL or title of a reference definition, on a line of its own. + if (line > 0 && REFERENCE.test(cm.getLine(line - 1)) && !isBlank(text)) { + return true; + } + + // A table row, with or without the leading pipe: an unescaped pipe + // outside inline code. A table's rows only make sense together. + if (/(^|[^\\])\|/.test(text.replace(/(`+)[^`]*?\1/g, ''))) { + return true; + } + + return isCodeLine(cm, line) || isInsideFence(cm, line); + } + + /** + * Where the styleable text of a line ends: before trailing whitespace, a + * hard-break backslash, and a heading's closing hashes. + */ + function contentEnd(text) { + var end = text.length; + + if (/^ {0,3}#{1,6}(\s|$)/.test(text)) { + var closing = text.match(/\s+#+\s*$/); + + if (closing) { + end = closing.index; + } + } + + while (end > 0 && /[\s\\]/.test(text.charAt(end - 1))) { + end--; + } + + return end; + } + + /** + * Narrow [start, end) to styleable text: after block markers, before the + * content's end, without surrounding whitespace, and including emphasis + * or code markers it sits just inside of. Null when nothing is left. + */ + function trimmed(text, start, end) { + var prefix = text.match(BLOCK_PREFIX)[1].length; + var limit = contentEnd(text); + + start = Math.max(start, prefix); + end = Math.min(end, limit); + + while (start < end && /\s/.test(text.charAt(start))) { + start++; + } + + while (end > start && /\s/.test(text.charAt(end - 1))) { + end--; + } + + while (start > prefix && end < limit + && text.charAt(start - 1) === text.charAt(end) + && MARKERS.indexOf(text.charAt(end)) !== -1 + ) { + start--; + end++; + } + + return start < end ? { start: start, end: end } : null; + } + + /** + * The spans in a text whose class passes the test, paired with their + * closing tags, as [[openIndex, openLength], [closeIndex, closeLength]]. + */ + function spanPairs(text, isTarget) { + var stack = []; + var pairs = []; + var match; + + SPAN_TAG.lastIndex = 0; + + while ((match = SPAN_TAG.exec(text)) !== null) { + if (match[1] !== undefined) { + stack.push({ index: match.index, length: match[0].length, target: isTarget(match[1]) }); + } else if (stack.length > 0) { + var opening = stack.pop(); + + if (opening.target) { + pairs.push([[opening.index, opening.length], [match.index, match[0].length]]); + } + } + } + + return pairs; + } + + /** + * Delete tags from a line, rightmost first, one small deletion each, so + * no selection bookmark ever sits inside a replaced range. + * + * @return {number} How many characters went before position limit. + */ + function deleteTags(cm, line, cuts, limit) { + var removed = 0; + + cuts.sort(function (a, b) { + return b[0] - a[0]; + }); + + for (var i = 0; i < cuts.length; i++) { + cm.replaceRange('', { line: line, ch: cuts[i][0] }, { line: line, ch: cuts[i][0] + cuts[i][1] }); + + if (cuts[i][0] < limit) { + removed += cuts[i][1]; + } + } + + return removed; + } + + /** + * Try to wrap [start, end) of a line; true when the edit was made. + */ + function tryWrap(editor, context, line, start, end, className, removeFirst) { + var cm = editor.codemirror; + var text = cm.getLine(line); + var body = text.slice(start, end); + var cuts = []; + + if (removeFirst) { + spanPairs(body, removeFirst).forEach(function (pair) { + cuts.push([start + pair[0][0], pair[0][1]], [start + pair[1][0], pair[1][1]]); + }); + + cuts.slice().sort(function (a, b) { + return b[0] - a[0]; + }).forEach(function (cut) { + body = body.slice(0, cut[0] - start) + body.slice(cut[0] - start + cut[1]); + }); + } + + if (!rendersCleanly(editor, context, line, text.slice(0, start), body, text.slice(end))) { + return false; + } + + end -= deleteTags(cm, line, cuts, end); + + // Two insertions rather than one replacement. + cm.replaceRange(CLOSE, { line: line, ch: end }); + cm.replaceRange(openTag(className), { line: line, ch: start }); + + return true; + } + + /** + * Style one line's parts of the selections, rightmost first so the + * positions of the others stay put. A part with nothing selected gets a + * bookmark inside the empty pair when one went in there. + */ + function wrapLine(editor, context, line, parts, className, removeFirst) { + var cm = editor.codemirror; + + if (isUntouchable(cm, line)) { + return; + } + + for (var i = 0; i < parts.length; i++) { + var part = parts[i]; + var text = cm.getLine(line); + + if (part.empty) { + // Nothing selected: an empty pair to type into, if one fits. + if (isBlank(text)) { + insertParagraph(cm, line, className, part); + } else if (text.charAt(part.start - 1) !== '`' && text.charAt(part.start) !== '`' + // Next to a backtick the two renderers can disagree on + // whether the pair lands inside inline code. + && rendersCleanly(editor, context, line, text.slice(0, part.start), '', text.slice(part.start)) + ) { + cm.replaceRange(openTag(className) + CLOSE, { line: line, ch: part.start }); + part.inside = cm.setBookmark({ line: line, ch: part.start + openTag(className).length }); + } + + continue; + } + + var piece = trimmed(text, part.start, part.end); + + if (piece === null || tryWrap(editor, context, line, piece.start, piece.end, className, removeFirst)) { + continue; + } + + // The selected part cuts through other formatting: the line's + // whole text, once, instead of any other part of it. + var whole = trimmed(text, 0, text.length); + + if (whole !== null) { + tryWrap(editor, context, line, whole.start, whole.end, className, removeFirst); + } + + return; + } + } + + /** + * Whether the nearest line with text in a direction (-1 up, 1 down) is + * part of a list or quote: marked, or indented under a list item. + */ + function continuesBlock(cm, line, direction) { + for (var i = line + direction; i >= 0 && i < cm.lineCount(); i += direction) { + var text = cm.getLine(i); + + if (!isBlank(text)) { + return /^\s*(?:(?:[*+-]|\d+[.)])(?:\s|$)|>)/.test(text) || /^( {2,}|\t)/.test(text); + } + } + + return false; + } + + /** + * An empty pair on a blank line, as a paragraph of its own: with a blank + * line added on either side that has text, it cannot join the paragraph + * before or after it. + */ + function insertParagraph(cm, line, className, part) { + // Not between the items of a list or the lines of a quote: a paragraph + // there would split it in two. + if (isBlank(cm.getLine(line)) && (continuesBlock(cm, line, -1) || continuesBlock(cm, line, 1))) { + return; + } + + var before = line > 0 && !isBlank(cm.getLine(line - 1)) ? '\n' : ''; + var after = line < cm.lineCount() - 1 && !isBlank(cm.getLine(line + 1)) ? '\n' : ''; + + cm.replaceRange(before + openTag(className) + CLOSE + after, { line: line, ch: 0 }, { line: line, ch: cm.getLine(line).length }); + part.inside = cm.setBookmark({ line: line + (before ? 1 : 0), ch: openTag(className).length }); + } + + /** + * Take spans off one line's parts: every pair with a tag in a part goes, + * both halves, except in code. + */ + function unwrapLine(editor, line, parts, isTarget) { + var cm = editor.codemirror; + + if (isUntouchable(cm, line)) { + return; + } + + var text = cm.getLine(line); + var code = []; + var match; + var inlineCode = /(`+)[^`]*?\1/g; + + while ((match = inlineCode.exec(text)) !== null) { + code.push([match.index, match.index + match[0].length]); + } + + var inCode = function (index) { + return code.some(function (range) { + return index >= range[0] && index < range[1]; + }); + }; + + var inParts = function (index, length) { + return parts.some(function (part) { + return index < part.end && index + length > part.start; + }); + }; + + var cuts = []; + + spanPairs(text, isTarget).forEach(function (pair) { + if (inCode(pair[0][0]) || inCode(pair[1][0])) { + return; + } + + if (inParts(pair[0][0], pair[0][1]) || inParts(pair[1][0], pair[1][1])) { + cuts.push(pair[0], pair[1]); + } + }); + + deleteTags(cm, line, cuts, 0); + } + + /** + * The selections as ranges, widened over span tags right around them and + * merged where they overlap or touch. + */ + function mergedRanges(cm) { + var list = cm.listSelections().map(function (selection) { + var reversed = comparePositions(selection.anchor, selection.head) > 0; + var range = takeInSurroundingTags(cm, { + from: reversed ? selection.head : selection.anchor, + to: reversed ? selection.anchor : selection.head + }); + + range.reversed = reversed; + + return range; + }).sort(function (a, b) { + return comparePositions(a.from, b.from); + }); + + var merged = []; + + list.forEach(function (range) { + var last = merged[merged.length - 1]; + + if (last && comparePositions(range.from, last.to) <= 0) { + if (comparePositions(range.to, last.to) > 0) { + last.to = range.to; + } + } else { + merged.push(range); + } + }); + + return merged; + } + + /** + * Widen a range over span tags sitting right around it on its lines, so + * selecting just the styled words is enough to change or remove the style. + */ + function takeInSurroundingTags(cm, range) { + var from = range.from; + var to = range.to; + var before = cm.getLine(from.line).slice(0, from.ch); + var after = cm.getLine(to.line).slice(to.ch); + + for (;;) { + var opening = before.match(/]*">$/); + var closing = after.match(/^<\/span>/); + + if (!opening || !closing) { + break; + } + + from = { line: from.line, ch: from.ch - opening[0].length }; + to = { line: to.line, ch: to.ch + closing[0].length }; + before = before.slice(0, before.length - opening[0].length); + after = after.slice(closing[0].length); + } + + return { from: from, to: to }; + } + + /** + * Every link reference definition in the document, to render paragraphs + * with, so reference links resolve as they do on the page. + */ + function references(cm) { + var found = []; + + for (var i = 0; i < cm.lineCount(); i++) { + if (REFERENCE.test(cm.getLine(i))) { + found.push(cm.getLine(i)); + + if (i + 1 < cm.lineCount() && !isBlank(cm.getLine(i + 1)) && !REFERENCE.test(cm.getLine(i + 1))) { + found.push(cm.getLine(i + 1)); + } + } + } + + return found.length > 0 ? '\n\n' + found.join('\n') : ''; + } + + /** + * Run a line handler over every selection, grouped by line so parts of + * several selections on one line cannot trip over each other, and keep + * the selections around the text afterwards, so a second button acts on + * the same words. + */ + function eachLine(editor, handler) { + var cm = editor.codemirror; + + cm.operation(function () { + var context = { references: references(cm) }; + var lines = {}; + + var marks = mergedRanges(cm).map(function (range) { + var empty = comparePositions(range.from, range.to) === 0; + var mark = { + from: cm.setBookmark(range.from), + to: cm.setBookmark(range.to, { insertLeft: true }), + reversed: range.reversed, + empty: empty, + parts: [] + }; + + for (var line = range.from.line; line <= range.to.line; line++) { + var part = { + start: line === range.from.line ? range.from.ch : 0, + end: line === range.to.line ? range.to.ch : cm.getLine(line).length, + empty: empty, + inside: null + }; + + mark.parts.push(part); + (lines[line] = lines[line] || []).push(part); + } + + return mark; + }); + + Object.keys(lines).map(Number).sort(function (a, b) { + return b - a; + }).forEach(function (line) { + handler(context, line, lines[line].sort(function (a, b) { + return b.start - a.start; + })); + }); + + var selections = []; + + marks.forEach(function (mark) { + var from = mark.from.find(); + var to = mark.to.find(); + + mark.from.clear(); + mark.to.clear(); + + if (!from || !to) { + return; + } + + // An inserted empty pair: the cursor goes inside it. + var inside = mark.empty && mark.parts[0].inside ? mark.parts[0].inside.find() : null; + + if (mark.empty && mark.parts[0].inside) { + mark.parts[0].inside.clear(); + } + + if (inside) { + from = to = inside; + } + + selections.push(mark.reversed ? { anchor: to, head: from } : { anchor: from, head: to }); + }); + + if (selections.length > 0) { + cm.setSelections(selections); + } + }); + + cm.focus(); + } + + /** + * The underline button, and the colour menu with one entry per colour the + * site renders plus one to take the colour off again. + */ + function styleButtons(styles) { + var buttons = []; + + if (!styles) { + return buttons; + } + + if (styles.underline) { + var isUnderline = function (className) { + return className === styles.underline; + }; + + buttons.push({ + name: 'underline', + className: 'fa fa-underline', + title: 'Underline', + action: function (editor) { + var cm = editor.codemirror; + + // Pressed on underlined text: take the underline off. + var underlined = mergedRanges(cm).some(function (range) { + return cm.getRange(range.from, range.to).indexOf(openTag(styles.underline)) !== -1; + }); + + eachLine(editor, function (context, line, parts) { + if (underlined) { + unwrapLine(editor, line, parts, isUnderline); + } else { + wrapLine(editor, context, line, parts, styles.underline, isUnderline); + } + }); + } + }); + } + + if (styles.colours && styles.colours.length > 0) { + var colourClasses = styles.colours.map(function (colour) { + return colour.class; + }); + + var isColour = function (className) { + return colourClasses.indexOf(className) !== -1; + }; + + var children = styles.colours.map(function (colour) { + return { + name: 'colour-' + colour.class, + title: colour.label, + icon: ' ' + escapeHtml(colour.label), + action: function (editor) { + eachLine(editor, function (context, line, parts) { + // A new colour replaces the old one rather than nesting. + wrapLine(editor, context, line, parts, colour.class, isColour); + }); + } + }; + }); + + children.push({ + name: 'colour-none', + title: 'Remove colour', + icon: ' Remove colour', + action: function (editor) { + eachLine(editor, function (context, line, parts) { + unwrapLine(editor, line, parts, isColour); + }); + } + }); + + buttons.push({ + name: 'colour', + className: 'fa fa-font', + title: 'Text colour', + children: children + }); + } + + return buttons; + } + + function readStyles(textarea) { + if (!textarea.dataset.markdownStyles) { + return null; + } + + try { + return JSON.parse(textarea.dataset.markdownStyles); + } catch (e) { + return null; + } + } + function enhance(textarea) { if (textarea.dataset.markdownEditorReady) { return; @@ -31,7 +959,14 @@ textarea.dataset.markdownEditorReady = '1'; - new EasyMDE({ + var styles = readStyles(textarea); + + // marked's raw HTML, for the styling buttons' check: EasyMDE hands it + // to the sanitizer before the browser can repair it. Nothing is + // changed, so the preview is as before. + var capture = { raw: '' }; + + var editor = new EasyMDE({ element: textarea, // The icon font is labelled by our own stylesheet; without this // EasyMDE injects a stylesheet from a third-party CDN. @@ -45,11 +980,33 @@ // article's form, is worse than losing it. autosave: { enabled: false }, status: ['lines', 'words'], - toolbar: TOOLBAR, + toolbar: ['bold', 'italic'].concat(styleButtons(styles), [ + 'heading', + '|', + 'quote', 'unordered-list', 'ordered-list', + '|', + 'link', 'image', 'table', 'code', + '|', + 'preview', 'side-by-side', 'fullscreen', + '|', + 'guide' + ]), + // Only with the button: bound on its own, the key would do nothing + // and still take the place of CodeMirror's own binding. + shortcuts: styles && styles.underline ? { underline: 'Cmd-U' } : {}, + renderingConfig: { + sanitizerFunction: function (html) { + capture.raw = html; + + return html; + } + }, // EasyMDE's preview is client side and only approximate; the server // renders the article that finally gets published. previewClass: ['editor-preview', 'markdown-body'] }); + + editor.markdownCapture = capture; } function init() {