From d068a65acca4fe408e588be1d14511b7758b15af Mon Sep 17 00:00:00 2001 From: paulsohier Date: Fri, 18 Sep 2026 11:26:07 +0100 Subject: [PATCH 1/5] Add colour and underline buttons to the Markdown editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Markdown has neither, and the website keeps both as from a fixed list it renders (phpbb-website-private#19). This gives authors buttons for them: * U (Ctrl/Cmd-U) underlines the selection, or takes the underline off again. * A opens a menu with one entry per palette colour, each showing its colour, plus "Remove colour". A new colour replaces the old one rather than nesting. The list comes from the textarea's data-markdown-styles attribute, which the website fills from the same constants its renderer checks. Without it the buttons are left out. The selection is wrapped one line at a time, with list, heading and quote markers kept outside the span, because the site only renders a span that opens and closes within one block. Selecting just the styled words is enough to change or remove their styling: tags right around the selection are taken in. Co-Authored-By: Claude Opus 5 --- css/markdown-editor.css | 21 ++++ js/markdown-editor.js | 251 ++++++++++++++++++++++++++++++++++++++-- 2 files changed, 260 insertions(+), 12 deletions(-) diff --git a/css/markdown-editor.css b/css/markdown-editor.css index b2d35f6..428e117 100644 --- a/css/markdown-editor.css +++ b/css/markdown-editor.css @@ -85,6 +85,27 @@ .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; +} /* ---- Rendered Markdown ---- */ diff --git a/js/markdown-editor.js b/js/markdown-editor.js index b512c20..a585fcf 100644 --- a/js/markdown-editor.js +++ b/js/markdown-editor.js @@ -8,21 +8,247 @@ * 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. The textarea's data-markdown-styles attribute carries that list + * (App\Form\Type\MarkdownEditorType), so the buttons offer exactly what the + * site will render and this script keeps no copy of it. Without the attribute + * the buttons are simply left out. */ (function () { 'use strict'; - var TOOLBAR = [ - 'bold', 'italic', 'heading', - '|', - 'quote', 'unordered-list', 'ordered-list', - '|', - 'link', 'image', 'table', 'code', - '|', - 'preview', 'side-by-side', 'fullscreen', - '|', - 'guide' - ]; + /** + * 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?)*)/; + + var SPAN_TAG = /|<\/span>/g; + + function escapeHtml(text) { + return String(text).replace(/[&<>"']/g, function (character) { + return '&#' + character.charCodeAt(0) + ';'; + }); + } + + function openTag(className) { + return ''; + } + + /** + * Widen the selection over span tags sitting right around it on its line, + * so selecting just the coloured words is enough to change or remove the + * colour. + */ + function takeInSurroundingTags(cm) { + var from = cm.getCursor('from'); + var to = cm.getCursor('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); + } + + cm.setSelection(from, to); + } + + /** + * Remove the spans whose class passes the test, with their matching + * closing tags, keeping everything between them. + */ + function unwrapSpans(text, isTarget) { + var stack = []; + var cuts = []; + 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) { + cuts.push([opening.index, opening.length], [match.index, match[0].length]); + } + } + } + + cuts.sort(function (a, b) { + return b[0] - a[0]; + }); + + for (var i = 0; i < cuts.length; i++) { + text = text.slice(0, cuts[i][0]) + text.slice(cuts[i][0] + cuts[i][1]); + } + + return text; + } + + /** + * Wrap the selection in a span, line by line: the site only renders a + * span that opens and closes within one paragraph, so one must never + * reach across lines. With nothing selected, insert an empty pair and put + * the cursor inside it. + */ + function wrapSelection(cm, className, removeFirst) { + takeInSurroundingTags(cm); + + var text = cm.getSelection(); + + if (removeFirst) { + text = unwrapSpans(text, removeFirst); + } + + if (text === '') { + cm.replaceSelection(openTag(className) + ''); + var cursor = cm.getCursor(); + cm.setCursor({ line: cursor.line, ch: cursor.ch - ''.length }); + cm.focus(); + + return; + } + + var firstLineFromStart = cm.getCursor('from').ch === 0; + + var lines = text.split('\n').map(function (line, index) { + var prefix = (index > 0 || firstLineFromStart) ? line.match(BLOCK_PREFIX)[1] : ''; + var body = line.slice(prefix.length); + var trailing = body.match(/\s*$/)[0]; + + body = body.slice(0, body.length - trailing.length); + + return body === '' ? line : prefix + openTag(className) + body + '' + trailing; + }); + + cm.replaceSelection(lines.join('\n'), 'around'); + cm.focus(); + } + + function removeFromSelection(cm, isTarget) { + takeInSurroundingTags(cm); + cm.replaceSelection(unwrapSpans(cm.getSelection(), isTarget), 'around'); + 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) { + buttons.push({ + name: 'underline', + className: 'fa fa-underline', + title: 'Underline', + action: function (editor) { + var cm = editor.codemirror; + + takeInSurroundingTags(cm); + + var text = cm.getSelection(); + var open = openTag(styles.underline); + + // Pressed again on underlined text: take it off. + if (text.indexOf(open) === 0 && text.slice(-''.length) === '') { + removeFromSelection(cm, function (className) { + return className === styles.underline; + }); + } else { + wrapSelection(cm, styles.underline, null); + } + } + }); + } + + 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) { + // A new colour replaces the old one rather than nesting. + wrapSelection(editor.codemirror, colour.class, isColour); + } + }; + }); + + children.push({ + name: 'colour-none', + title: 'Remove colour', + icon: ' Remove colour', + action: function (editor) { + removeFromSelection(editor.codemirror, 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 toolbar(textarea) { + var styles = styleButtons(readStyles(textarea)); + + return ['bold', 'italic'].concat(styles, [ + 'heading', + '|', + 'quote', 'unordered-list', 'ordered-list', + '|', + 'link', 'image', 'table', 'code', + '|', + 'preview', 'side-by-side', 'fullscreen', + '|', + 'guide' + ]); + } function enhance(textarea) { if (textarea.dataset.markdownEditorReady) { @@ -45,7 +271,8 @@ // article's form, is worse than losing it. autosave: { enabled: false }, status: ['lines', 'words'], - toolbar: TOOLBAR, + toolbar: toolbar(textarea), + shortcuts: { underline: 'Cmd-U' }, // EasyMDE's preview is client side and only approximate; the server // renders the article that finally gets published. previewClass: ['editor-preview', 'markdown-body'] From 5e688dbbc2a730a434e2af957398b84eabfbd85e Mon Sep 17 00:00:00 2001 From: paulsohier Date: Fri, 18 Sep 2026 11:35:04 +0100 Subject: [PATCH 2/5] Keep the editor's styling within what the site can render Review of the colour and underline buttons found edits that would put HTML text or broken layout on published pages. The buttons now: * Style every selection, not just the primary one. * Leave lines alone that a span would break: code blocks (from CodeMirror's own tokens, indented code included), code fences, rules, setext underlines and table rows. * Never cut into inline code, emphasis, links or another span. A selection that would falls back to the whole line's content, or leaves the line alone if even that would. A selection just inside emphasis or code markers takes the markers in. * Start after list markers even when the selection starts inside one, and keep a hard-break backslash outside the span. * Take the underline off wherever it is in the selection, also under a colour. * Clean whole lines when removing a colour from a selection that holds half of a pair. * Bind Cmd/Ctrl-U only when the underline button exists. The menu labels are no longer bold. Co-Authored-By: Claude Opus 5 --- css/markdown-editor.css | 1 + js/markdown-editor.js | 371 +++++++++++++++++++++++++++++++--------- 2 files changed, 287 insertions(+), 85 deletions(-) diff --git a/css/markdown-editor.css b/css/markdown-editor.css index 428e117..28e8e5c 100644 --- a/css/markdown-editor.css +++ b/css/markdown-editor.css @@ -105,6 +105,7 @@ 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 a585fcf..3036c71 100644 --- a/js/markdown-editor.js +++ b/js/markdown-editor.js @@ -11,22 +11,41 @@ * * Colour and underline: Markdown has neither, so the website keeps them as * with a class from a fixed list, and renders only those - * spans. The textarea's data-markdown-styles attribute carries that list - * (App\Form\Type\MarkdownEditorType), so the buttons offer exactly what the - * site will render and this script keeps no copy of it. Without the attribute - * the buttons are simply left out. + * spans, and only when a span opens and closes within one block without + * crossing other formatting. 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. + * + * The buttons therefore style text line by line, leave code, tables and other + * block syntax alone, and never put a span halfway into bold, links or code: + * a span the site could not render would show on the page as HTML text. */ (function () { 'use strict'; + var CLOSE = ''; + /** * 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?)*)/; + /** Lines whose syntax a span would break, styled or not. */ + var UNTOUCHABLE_LINE = [ + /^\s*(`{3,}|~{3,})/, // code fence + /^ {0,3}([-*_])( *\1){2,} *$/, // thematic break + /^ {0,3}(=+|-+) *$/, // setext heading underline + /^\s*\|/, // table row + /^\s*:?-+:?\s*(\|\s*:?-+:?\s*)+\|?\s*$/ // table delimiter row + ]; + 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) + ';'; @@ -37,32 +56,63 @@ return ''; } + function count(text, pattern) { + return (text.match(pattern) || []).length; + } + /** - * Widen the selection over span tags sitting right around it on its line, - * so selecting just the coloured words is enough to change or remove the - * colour. + * Whether a piece of a line can be wrapped in a span without cutting + * through inline code, emphasis, a link or another span. */ - function takeInSurroundingTags(cm) { - var from = cm.getCursor('from'); - var to = cm.getCursor('to'); - var before = cm.getLine(from.line).slice(0, from.ch); - var after = cm.getLine(to.line).slice(to.ch); + function isBalanced(text) { + var t = text.replace(/\\./g, ''); - for (;;) { - var opening = before.match(/$/); - var closing = after.match(/^<\/span>/); + t = t.replace(/(`+)[\s\S]*?\1/g, ''); - if (!opening || !closing) { - break; + if (t.indexOf('`') !== -1) { + return false; + } + + return count(t, /\*\*/g) % 2 === 0 + && count(t, /__/g) % 2 === 0 + && count(t.replace(/\*\*/g, ''), /\*/g) % 2 === 0 + && count(t, /\[/g) === count(t, /\]/g) + && count(t, /\(/g) === count(t, /\)/g) + && count(t, //g) === count(t, /<\/span>/g); + } + + /** + * Whether a whole line is code: a line in a fenced or indented code block. + * EasyMDE's Markdown mode marks code as "comment". + */ + function isCodeLine(cm, line) { + var tokens = cm.getLineTokens(line); + var code = false; + + for (var i = 0; i < tokens.length; i++) { + // Indentation is a token of its own, without a type. + if (/^\s*$/.test(tokens[i].string)) { + continue; } - 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); + if (!/\bcomment\b/.test(tokens[i].type || '')) { + return false; + } + + code = true; + } + + return code; + } + + function isUntouchable(cm, line, text) { + for (var i = 0; i < UNTOUCHABLE_LINE.length; i++) { + if (UNTOUCHABLE_LINE[i].test(text)) { + return true; + } } - cm.setSelection(from, to); + return isCodeLine(cm, line); } /** @@ -99,49 +149,199 @@ return text; } + function comparePositions(a, b) { + return a.line === b.line ? a.ch - b.ch : a.line - b.line; + } + + /** + * Each selection as {from, to}, last in the document first, so styling + * one never moves the others. + */ + function ranges(cm) { + return cm.listSelections().map(function (selection) { + var ordered = comparePositions(selection.anchor, selection.head) <= 0; + + return { + from: ordered ? selection.anchor : selection.head, + to: ordered ? selection.head : selection.anchor + }; + }).sort(function (a, b) { + return comparePositions(b.from, a.from); + }); + } + + /** + * 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 }; + } + /** - * Wrap the selection in a span, line by line: the site only renders a - * span that opens and closes within one paragraph, so one must never - * reach across lines. With nothing selected, insert an empty pair and put - * the cursor inside it. + * The part of one line a style should go on: within the range, after any + * block markers, without surrounding whitespace or a hard-break backslash, + * and including emphasis or code markers it sits just inside of. Null when + * there is nothing on the line to style. */ - function wrapSelection(cm, className, removeFirst) { - takeInSurroundingTags(cm); + function segment(cm, range, line) { + var text = cm.getLine(line); + var prefix = text.match(BLOCK_PREFIX)[1].length; + var start = Math.max(line === range.from.line ? range.from.ch : 0, prefix); + var end = line === range.to.line ? range.to.ch : text.length; + + while (start < end && /\s/.test(text.charAt(start))) { + start++; + } - var text = cm.getSelection(); + while (end > start && /[\s\\]/.test(text.charAt(end - 1))) { + end--; + } - if (removeFirst) { - text = unwrapSpans(text, removeFirst); + while (start > prefix && end < text.length + && text.charAt(start - 1) === text.charAt(end) + && MARKERS.indexOf(text.charAt(end)) !== -1 + ) { + start--; + end++; } - if (text === '') { - cm.replaceSelection(openTag(className) + ''); - var cursor = cm.getCursor(); - cm.setCursor({ line: cursor.line, ch: cursor.ch - ''.length }); - cm.focus(); + return start < end ? { line: line, start: start, end: end, text: text, prefix: prefix } : null; + } + + /** + * The whole styleable content of a line, for when the selected part of it + * cuts through other formatting. + */ + function wholeLine(piece) { + var start = piece.prefix; + var end = piece.text.length; + + while (end > start && /[\s\\]/.test(piece.text.charAt(end - 1))) { + end--; + } + + return { line: piece.line, start: start, end: end, text: piece.text, prefix: piece.prefix }; + } + + /** + * Style a range: wrap each line's part of it in its own span. + * + * @param {function|null} removeFirst Spans to take off first, so a new + * colour replaces the old one. + */ + function wrapRange(cm, range, className, removeFirst) { + range = takeInSurroundingTags(cm, range); + + if (comparePositions(range.from, range.to) === 0) { + // Nothing selected: an empty pair to type into, unless in code. + if (/\bcomment\b/.test(cm.getTokenTypeAt(range.from) || '') || isUntouchable(cm, range.from.line, cm.getLine(range.from.line))) { + return; + } + + cm.replaceRange(openTag(className) + CLOSE, range.from); + + // With a single cursor, put it inside the pair to type into. + if (cm.listSelections().length === 1) { + cm.setCursor({ line: range.from.line, ch: range.from.ch + openTag(className).length }); + } return; } - var firstLineFromStart = cm.getCursor('from').ch === 0; + for (var line = range.to.line; line >= range.from.line; line--) { + if (isUntouchable(cm, line, cm.getLine(line))) { + continue; + } - var lines = text.split('\n').map(function (line, index) { - var prefix = (index > 0 || firstLineFromStart) ? line.match(BLOCK_PREFIX)[1] : ''; - var body = line.slice(prefix.length); - var trailing = body.match(/\s*$/)[0]; + var piece = segment(cm, range, line); - body = body.slice(0, body.length - trailing.length); + if (piece === null) { + continue; + } - return body === '' ? line : prefix + openTag(className) + body + '' + trailing; - }); + var body = piece.text.slice(piece.start, piece.end); - cm.replaceSelection(lines.join('\n'), 'around'); - cm.focus(); + if (removeFirst) { + body = unwrapSpans(body, removeFirst); + } + + if (!isBalanced(body)) { + piece = wholeLine(piece); + body = piece.text.slice(piece.start, piece.end); + + if (removeFirst) { + body = unwrapSpans(body, removeFirst); + } + + if (body === '' || !isBalanced(body)) { + continue; + } + } + + cm.replaceRange( + openTag(className) + body + CLOSE, + { line: line, ch: piece.start }, + { line: line, ch: piece.end } + ); + } } - function removeFromSelection(cm, isTarget) { - takeInSurroundingTags(cm); - cm.replaceSelection(unwrapSpans(cm.getSelection(), isTarget), 'around'); + /** + * Take spans off a range. When the range holds half of a pair, the whole + * lines are cleaned instead: spans never reach across lines, so that is + * where the other half is. + */ + function unwrapRange(cm, range, isTarget) { + range = takeInSurroundingTags(cm, range); + + var text = cm.getRange(range.from, range.to); + + if (count(text, //g) !== count(text, /<\/span>/g)) { + range = { + from: { line: range.from.line, ch: 0 }, + to: { line: range.to.line, ch: cm.getLine(range.to.line).length } + }; + text = cm.getRange(range.from, range.to); + } + + var cleaned = unwrapSpans(text, isTarget); + + if (cleaned !== text) { + cm.replaceRange(cleaned, range.from, range.to); + } + } + + function eachRange(editor, handler) { + var cm = editor.codemirror; + + cm.operation(function () { + var list = ranges(cm); + + for (var i = 0; i < list.length; i++) { + handler(cm, list[i]); + } + }); + cm.focus(); } @@ -157,26 +357,25 @@ } 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; - - takeInSurroundingTags(cm); - - var text = cm.getSelection(); - var open = openTag(styles.underline); - - // Pressed again on underlined text: take it off. - if (text.indexOf(open) === 0 && text.slice(-''.length) === '') { - removeFromSelection(cm, function (className) { - return className === styles.underline; - }); - } else { - wrapSelection(cm, styles.underline, null); - } + eachRange(editor, function (cm, range) { + var widened = takeInSurroundingTags(cm, range); + + // Pressed on underlined text: take the underline off. + if (cm.getRange(widened.from, widened.to).indexOf(openTag(styles.underline)) !== -1) { + unwrapRange(cm, range, isUnderline); + } else { + wrapRange(cm, range, styles.underline, null); + } + }); } }); } @@ -196,8 +395,10 @@ title: colour.label, icon: ' ' + escapeHtml(colour.label), action: function (editor) { - // A new colour replaces the old one rather than nesting. - wrapSelection(editor.codemirror, colour.class, isColour); + eachRange(editor, function (cm, range) { + // A new colour replaces the old one rather than nesting. + wrapRange(cm, range, colour.class, isColour); + }); } }; }); @@ -207,7 +408,9 @@ title: 'Remove colour', icon: ' Remove colour', action: function (editor) { - removeFromSelection(editor.codemirror, isColour); + eachRange(editor, function (cm, range) { + unwrapRange(cm, range, isColour); + }); } }); @@ -234,22 +437,6 @@ } } - function toolbar(textarea) { - var styles = styleButtons(readStyles(textarea)); - - return ['bold', 'italic'].concat(styles, [ - 'heading', - '|', - 'quote', 'unordered-list', 'ordered-list', - '|', - 'link', 'image', 'table', 'code', - '|', - 'preview', 'side-by-side', 'fullscreen', - '|', - 'guide' - ]); - } - function enhance(textarea) { if (textarea.dataset.markdownEditorReady) { return; @@ -257,6 +444,8 @@ textarea.dataset.markdownEditorReady = '1'; + var styles = readStyles(textarea); + new EasyMDE({ element: textarea, // The icon font is labelled by our own stylesheet; without this @@ -271,8 +460,20 @@ // article's form, is worse than losing it. autosave: { enabled: false }, status: ['lines', 'words'], - toolbar: toolbar(textarea), - shortcuts: { underline: 'Cmd-U' }, + 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' } : {}, // EasyMDE's preview is client side and only approximate; the server // renders the article that finally gets published. previewClass: ['editor-preview', 'markdown-body'] From 83f4e52b3d545786e493459be7ecae15bf26d1f9 Mon Sep 17 00:00:00 2001 From: paulsohier Date: Fri, 18 Sep 2026 11:42:22 +0100 Subject: [PATCH 3/5] Try every editor styling edit before making it A second review still found edits the site could not render: two selections on one line tripping over each other, single-underscore emphasis and double-backtick code slipping past the balance check, tables without a leading pipe, reference definitions, a heading's closing hashes, image alt text, and empty pairs dropped into list markers, link URLs or between bold markers. The selection was also lost after every click, so a second button acted on nothing. Instead of more rules about Markdown, each edit is now tried first with EasyMDE's own renderer. The line is rendered with a probe span and without it. The edit is made only if the probe comes out as an element directly inside its block, the site's rule too, and taking it out again gives back exactly the rendering without it. If the selected part of a line fails, the line's whole text is tried once; if that fails too, the line is left as it is. DOMParser renders into a detached document, so nothing runs and nothing loads. Also: * Selections are handled per line, rightmost part first, so parts of several selections on one line cannot shift each other. * Bookmarks keep each selection around the styled text afterwards, so red then underline, or red then blue, act on the same words. * Any line with an unescaped pipe outside code counts as a table row and is left alone, as are reference definitions. * A heading's closing hashes stay outside the span. Co-Authored-By: Claude Opus 5 --- js/markdown-editor.js | 387 ++++++++++++++++++++++++++---------------- 1 file changed, 237 insertions(+), 150 deletions(-) diff --git a/js/markdown-editor.js b/js/markdown-editor.js index 3036c71..c891e63 100644 --- a/js/markdown-editor.js +++ b/js/markdown-editor.js @@ -11,21 +11,31 @@ * * 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 within one block without + * spans, and only when a span opens and closes inside one block without * crossing other formatting. 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. * - * The buttons therefore style text line by line, leave code, tables and other - * block syntax alone, and never put a span halfway into bold, links or code: - * a span the site could not render would show on the page as HTML text. + * A span the site cannot render shows on the page as HTML text, so 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 line is rendered with a + * probe span and without it, and the edit is made only if the probe comes out + * as an element directly inside its block and taking it out again gives back + * exactly the rendering the line 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 CLOSE = ''; + var PROBE = 'markdown-editor-probe'; + + /** Elements a span may sit directly inside; the site's rule too. */ + var BLOCK_PARENTS = ['P', 'LI', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'TD', 'TH']; + /** * 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. @@ -34,11 +44,10 @@ /** Lines whose syntax a span would break, styled or not. */ var UNTOUCHABLE_LINE = [ - /^\s*(`{3,}|~{3,})/, // code fence - /^ {0,3}([-*_])( *\1){2,} *$/, // thematic break - /^ {0,3}(=+|-+) *$/, // setext heading underline - /^\s*\|/, // table row - /^\s*:?-+:?\s*(\|\s*:?-+:?\s*)+\|?\s*$/ // table delimiter row + /^\s*(`{3,}|~{3,})/, // code fence + /^ {0,3}([-*_])( *\1){2,} *$/, // thematic break + /^ {0,3}(=+|-+) *$/, // setext heading underline + /^ {0,3}\[[^\]]+\]:/ // link reference definition ]; var SPAN_TAG = /|<\/span>/g; @@ -60,25 +69,38 @@ return (text.match(pattern) || []).length; } + function comparePositions(a, b) { + return a.line === b.line ? a.ch - b.ch : a.line - b.line; + } + /** - * Whether a piece of a line can be wrapped in a span without cutting - * through inline code, emphasis, a link or another span. + * Markdown rendered by EasyMDE's own renderer, as a detached document: + * DOMParser runs no scripts and loads no images. */ - function isBalanced(text) { - var t = text.replace(/\\./g, ''); + function render(editor, markdown) { + return new DOMParser().parseFromString(editor.markdown(markdown), 'text/html').body; + } - t = t.replace(/(`+)[\s\S]*?\1/g, ''); + /** + * Whether wrapping body in a span, between before and after on one line, + * renders as intended and changes nothing else. + */ + function rendersCleanly(editor, before, body, after) { + var plain = render(editor, before + body + after); + var probed = render(editor, before + openTag(PROBE) + body + CLOSE + after); + var span = probed.querySelector('span.' + PROBE); - if (t.indexOf('`') !== -1) { + if (!span || BLOCK_PARENTS.indexOf(span.parentNode.nodeName) === -1) { return false; } - return count(t, /\*\*/g) % 2 === 0 - && count(t, /__/g) % 2 === 0 - && count(t.replace(/\*\*/g, ''), /\*/g) % 2 === 0 - && count(t, /\[/g) === count(t, /\]/g) - && count(t, /\(/g) === count(t, /\)/g) - && count(t, //g) === count(t, /<\/span>/g); + while (span.firstChild) { + span.parentNode.insertBefore(span.firstChild, span); + } + + span.parentNode.removeChild(span); + + return probed.innerHTML === plain.innerHTML; } /** @@ -112,9 +134,68 @@ } } + // 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); } + /** + * 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; + } + /** * Remove the spans whose class passes the test, with their matching * closing tags, keeping everything between them. @@ -149,27 +230,6 @@ return text; } - function comparePositions(a, b) { - return a.line === b.line ? a.ch - b.ch : a.line - b.line; - } - - /** - * Each selection as {from, to}, last in the document first, so styling - * one never moves the others. - */ - function ranges(cm) { - return cm.listSelections().map(function (selection) { - var ordered = comparePositions(selection.anchor, selection.head) <= 0; - - return { - from: ordered ? selection.anchor : selection.head, - to: ordered ? selection.head : selection.anchor - }; - }).sort(function (a, b) { - return comparePositions(b.from, a.from); - }); - } - /** * 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. @@ -198,148 +258,165 @@ } /** - * The part of one line a style should go on: within the range, after any - * block markers, without surrounding whitespace or a hard-break backslash, - * and including emphasis or code markers it sits just inside of. Null when - * there is nothing on the line to style. + * Try to wrap [start, end) of a line; true when the edit was made. */ - function segment(cm, range, line) { + function tryWrap(editor, line, start, end, className, removeFirst) { + var cm = editor.codemirror; var text = cm.getLine(line); - var prefix = text.match(BLOCK_PREFIX)[1].length; - var start = Math.max(line === range.from.line ? range.from.ch : 0, prefix); - var end = line === range.to.line ? range.to.ch : text.length; + var body = text.slice(start, end); - while (start < end && /\s/.test(text.charAt(start))) { - start++; + if (removeFirst) { + body = unwrapSpans(body, removeFirst); } - while (end > start && /[\s\\]/.test(text.charAt(end - 1))) { - end--; + if (!rendersCleanly(editor, text.slice(0, start), body, text.slice(end))) { + return false; } - while (start > prefix && end < text.length - && text.charAt(start - 1) === text.charAt(end) - && MARKERS.indexOf(text.charAt(end)) !== -1 - ) { - start--; - end++; - } + cm.replaceRange(openTag(className) + body + CLOSE, { line: line, ch: start }, { line: line, ch: end }); - return start < end ? { line: line, start: start, end: end, text: text, prefix: prefix } : null; + return true; } /** - * The whole styleable content of a line, for when the selected part of it - * cuts through other formatting. + * Style one line's parts of the selections, rightmost first so the + * positions of the others stay put. A part with nothing selected is + * marked inserted when an empty pair went in there. */ - function wholeLine(piece) { - var start = piece.prefix; - var end = piece.text.length; + function wrapLine(editor, line, parts, className, removeFirst) { + var cm = editor.codemirror; - while (end > start && /[\s\\]/.test(piece.text.charAt(end - 1))) { - end--; + if (isUntouchable(cm, line, cm.getLine(line))) { + return; } - return { line: piece.line, start: start, end: end, text: piece.text, prefix: piece.prefix }; - } - - /** - * Style a range: wrap each line's part of it in its own span. - * - * @param {function|null} removeFirst Spans to take off first, so a new - * colour replaces the old one. - */ - function wrapRange(cm, range, className, removeFirst) { - range = takeInSurroundingTags(cm, range); - - if (comparePositions(range.from, range.to) === 0) { - // Nothing selected: an empty pair to type into, unless in code. - if (/\bcomment\b/.test(cm.getTokenTypeAt(range.from) || '') || isUntouchable(cm, range.from.line, cm.getLine(range.from.line))) { - return; - } + for (var i = 0; i < parts.length; i++) { + var part = parts[i]; + var text = cm.getLine(line); - cm.replaceRange(openTag(className) + CLOSE, range.from); + if (part.empty) { + // Nothing selected: an empty pair to type into, if one fits. + if (rendersCleanly(editor, text.slice(0, part.start), '', text.slice(part.start))) { + cm.replaceRange(openTag(className) + CLOSE, { line: line, ch: part.start }); + part.inserted = true; + } - // With a single cursor, put it inside the pair to type into. - if (cm.listSelections().length === 1) { - cm.setCursor({ line: range.from.line, ch: range.from.ch + openTag(className).length }); + continue; } - return; - } + var piece = trimmed(text, part.start, part.end); - for (var line = range.to.line; line >= range.from.line; line--) { - if (isUntouchable(cm, line, cm.getLine(line))) { + if (piece === null || tryWrap(editor, line, piece.start, piece.end, className, removeFirst)) { continue; } - var piece = segment(cm, range, line); + // 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 (piece === null) { - continue; + if (whole !== null) { + tryWrap(editor, line, whole.start, whole.end, className, removeFirst); } - var body = piece.text.slice(piece.start, piece.end); + return; + } + } - if (removeFirst) { - body = unwrapSpans(body, removeFirst); - } + /** + * Take spans off one line's parts. When a part holds half of a pair the + * whole line is cleaned instead: spans never reach across lines, so that + * is where the other half is. + */ + function unwrapLine(editor, line, parts, isTarget) { + var cm = editor.codemirror; - if (!isBalanced(body)) { - piece = wholeLine(piece); - body = piece.text.slice(piece.start, piece.end); + for (var i = 0; i < parts.length; i++) { + var text = cm.getLine(line); + var start = parts[i].start; + var end = parts[i].end; + var slice = text.slice(start, end); - if (removeFirst) { - body = unwrapSpans(body, removeFirst); - } + if (count(slice, //g) !== count(slice, /<\/span>/g)) { + cm.replaceRange(unwrapSpans(text, isTarget), { line: line, ch: 0 }, { line: line, ch: text.length }); - if (body === '' || !isBalanced(body)) { - continue; - } + return; } - cm.replaceRange( - openTag(className) + body + CLOSE, - { line: line, ch: piece.start }, - { line: line, ch: piece.end } - ); + var cleaned = unwrapSpans(slice, isTarget); + + if (cleaned !== slice) { + cm.replaceRange(cleaned, { line: line, ch: start }, { line: line, ch: end }); + } } } /** - * Take spans off a range. When the range holds half of a pair, the whole - * lines are cleaned instead: spans never reach across lines, so that is - * where the other half is. + * 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 unwrapRange(cm, range, isTarget) { - range = takeInSurroundingTags(cm, range); + function eachLine(editor, handler) { + var cm = editor.codemirror; - var text = cm.getRange(range.from, range.to); + cm.operation(function () { + var selections = cm.listSelections().map(function (selection) { + var ordered = comparePositions(selection.anchor, selection.head) <= 0; - if (count(text, //g) !== count(text, /<\/span>/g)) { - range = { - from: { line: range.from.line, ch: 0 }, - to: { line: range.to.line, ch: cm.getLine(range.to.line).length } - }; - text = cm.getRange(range.from, range.to); - } + return takeInSurroundingTags(cm, { + from: ordered ? selection.anchor : selection.head, + to: ordered ? selection.head : selection.anchor + }); + }); - var cleaned = unwrapSpans(text, isTarget); + var lines = {}; + var marks = []; + + selections.forEach(function (range, index) { + var empty = comparePositions(range.from, range.to) === 0; + + marks.push({ + from: cm.setBookmark(range.from), + to: cm.setBookmark(range.to, { insertLeft: true }), + 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, + inserted: false + }; + + marks[index].parts.push(part); + (lines[line] = lines[line] || []).push(part); + } + }); - if (cleaned !== text) { - cm.replaceRange(cleaned, range.from, range.to); - } - } + Object.keys(lines).map(Number).sort(function (a, b) { + return b - a; + }).forEach(function (line) { + handler(line, lines[line].sort(function (a, b) { + return b.start - a.start; + })); + }); - function eachRange(editor, handler) { - var cm = editor.codemirror; + cm.setSelections(marks.map(function (mark) { + var from = mark.from.find(); + var to = mark.to.find(); - cm.operation(function () { - var list = ranges(cm); + mark.from.clear(); + mark.to.clear(); - for (var i = 0; i < list.length; i++) { - handler(cm, list[i]); - } + // An inserted empty pair: the cursor goes inside it. + if (mark.empty && mark.parts[0].inserted) { + from = to = { line: to.line, ch: to.ch - CLOSE.length }; + } + + return { anchor: from, head: to }; + })); }); cm.focus(); @@ -366,14 +443,24 @@ className: 'fa fa-underline', title: 'Underline', action: function (editor) { - eachRange(editor, function (cm, range) { - var widened = takeInSurroundingTags(cm, range); + var cm = editor.codemirror; + + // Pressed on underlined text: take the underline off. + var underlined = cm.listSelections().some(function (selection) { + var ordered = comparePositions(selection.anchor, selection.head) <= 0; + var range = takeInSurroundingTags(cm, { + from: ordered ? selection.anchor : selection.head, + to: ordered ? selection.head : selection.anchor + }); + + return cm.getRange(range.from, range.to).indexOf(openTag(styles.underline)) !== -1; + }); - // Pressed on underlined text: take the underline off. - if (cm.getRange(widened.from, widened.to).indexOf(openTag(styles.underline)) !== -1) { - unwrapRange(cm, range, isUnderline); + eachLine(editor, function (line, parts) { + if (underlined) { + unwrapLine(editor, line, parts, isUnderline); } else { - wrapRange(cm, range, styles.underline, null); + wrapLine(editor, line, parts, styles.underline, null); } }); } @@ -395,9 +482,9 @@ title: colour.label, icon: ' ' + escapeHtml(colour.label), action: function (editor) { - eachRange(editor, function (cm, range) { + eachLine(editor, function (line, parts) { // A new colour replaces the old one rather than nesting. - wrapRange(cm, range, colour.class, isColour); + wrapLine(editor, line, parts, colour.class, isColour); }); } }; @@ -408,8 +495,8 @@ title: 'Remove colour', icon: ' Remove colour', action: function (editor) { - eachRange(editor, function (cm, range) { - unwrapRange(cm, range, isColour); + eachLine(editor, function (line, parts) { + unwrapLine(editor, line, parts, isColour); }); } }); From bfb67232f6c21789e8ef8093bd1e5c95174554b9 Mon Sep 17 00:00:00 2001 From: paulsohier Date: Fri, 18 Sep 2026 11:58:58 +0100 Subject: [PATCH 4/5] Check editor styling edits against raw HTML and whole paragraphs A third review found edits that still slipped through: * The probe check read the browser's repaired DOM, so marked's badly nested output for a selection from before bold to inside it looked clean. marked's raw HTML is now captured through EasyMDE's sanitizerFunction hook, unchanged, and the probe must hold properly nested inline content with no block in it. * Lines were rendered alone, missing bold, links and code spans that cross a line break, and reference links. The whole paragraph is now rendered, with every reference definition of the document. * Fences and code inside quotes and list items were not recognised. Lines inside a fence are now found by counting fences above. * Replacing a range that held a selection bookmark threw and lost the selection. Edits are now small insertions and deletions, and a lost bookmark is skipped. * Overlapping or touching selections are merged, and reversed ones keep their direction. * Removing a style no longer touches code, and removes both halves of a pair. * An empty line gets an empty pair as a paragraph of its own. A reference definition's continuation line, and a body whose span tags do not pair up, are left alone. Checked with the reviewer's puppeteer harness and fuzzer against the site's SafeMarkdown. Ordinary text in the case files comes out right. What the fuzzer still flags is delimiter soup where marked and CommonMark disagree about emphasis; the site now drops rather than shows any span it cannot restore. Co-Authored-By: Claude Opus 5 --- js/markdown-editor.js | 542 ++++++++++++++++++++++++++++++++---------- 1 file changed, 412 insertions(+), 130 deletions(-) diff --git a/js/markdown-editor.js b/js/markdown-editor.js index c891e63..d0243dc 100644 --- a/js/markdown-editor.js +++ b/js/markdown-editor.js @@ -12,19 +12,20 @@ * 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. 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. + * 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. * - * A span the site cannot render shows on the page as HTML text, so 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 line is rendered with a - * probe span and without it, and the edit is made only if the probe comes out - * as an element directly inside its block and taking it out again gives back - * exactly the rendering the line 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. + * 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'; @@ -33,8 +34,10 @@ var PROBE = 'markdown-editor-probe'; - /** Elements a span may sit directly inside; the site's rule too. */ - var BLOCK_PARENTS = ['P', 'LI', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'TD', 'TH']; + /** 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 @@ -42,15 +45,21 @@ */ 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 = [ - /^\s*(`{3,}|~{3,})/, // code fence + FENCE, /^ {0,3}([-*_])( *\1){2,} *$/, // thematic break /^ {0,3}(=+|-+) *$/, // setext heading underline - /^ {0,3}\[[^\]]+\]:/ // link reference definition + REFERENCE ]; - var SPAN_TAG = /|<\/span>/g; + var SPAN_TAG = /]*)">|<\/span>/g; /** Emphasis and code markers a selection may sit just inside of. */ var MARKERS = '*_~`'; @@ -65,32 +74,143 @@ return ''; } - function count(text, pattern) { - return (text.match(pattern) || []).length; - } - 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, as a detached document: - * DOMParser runs no scripts and loads no images. + * 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) { - return new DOMParser().parseFromString(editor.markdown(markdown), 'text/html').body; + 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; + } + + /** + * 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) { + var first = line; + var last = line; + + while (first > 0 && !isBlank(cm.getLine(first - 1))) { + first--; + } + + while (last < cm.lineCount() - 1 && !isBlank(cm.getLine(last + 1))) { + last++; + } + + var lines = []; + + for (var i = first; i <= last; i++) { + lines.push(i === line ? text : cm.getLine(i)); + } + + return lines.join('\n') + context.references; } /** * Whether wrapping body in a span, between before and after on one line, * renders as intended and changes nothing else. */ - function rendersCleanly(editor, before, body, after) { - var plain = render(editor, before + body + after); - var probed = render(editor, before + openTag(PROBE) + body + CLOSE + after); - var span = probed.querySelector('span.' + PROBE); + 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; + } + + 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; + } - if (!span || BLOCK_PARENTS.indexOf(span.parentNode.nodeName) === -1) { + var span = probed.body.querySelector('span.' + PROBE); + + if (!span) { return false; } @@ -100,24 +220,26 @@ span.parentNode.removeChild(span); - return probed.innerHTML === plain.innerHTML; + return probed.body.innerHTML === plain.body.innerHTML; } /** - * Whether a whole line is code: a line in a fenced or indented code block. - * EasyMDE's Markdown mode marks code as "comment". + * 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++) { - // Indentation is a token of its own, without a type. - if (/^\s*$/.test(tokens[i].string)) { + 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; } - if (!/\bcomment\b/.test(tokens[i].type || '')) { + if (!/\bcomment\b/.test(type)) { return false; } @@ -127,20 +249,44 @@ return code; } - function isUntouchable(cm, line, text) { + /** + * Whether a line lies between the fences of a fenced code block: an odd + * number of fences above it. + */ + function isInsideFence(cm, line) { + var fences = 0; + + for (var i = 0; i < line; i++) { + if (FENCE.test(withoutPrefix(cm.getLine(i)))) { + fences++; + } + } + + return fences % 2 === 1; + } + + 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)) { + 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); + return isCodeLine(cm, line) || isInsideFence(cm, line); } /** @@ -197,12 +343,12 @@ } /** - * Remove the spans whose class passes the test, with their matching - * closing tags, keeping everything between them. + * The spans in a text whose class passes the test, paired with their + * closing tags, as [[openIndex, openLength], [closeIndex, closeLength]]. */ - function unwrapSpans(text, isTarget) { + function spanPairs(text, isTarget) { var stack = []; - var cuts = []; + var pairs = []; var match; SPAN_TAG.lastIndex = 0; @@ -214,79 +360,81 @@ var opening = stack.pop(); if (opening.target) { - cuts.push([opening.index, opening.length], [match.index, match[0].length]); + pairs.push([[opening.index, opening.length], [match.index, match[0].length]]); } } } - cuts.sort(function (a, b) { - return b[0] - a[0]; - }); - - for (var i = 0; i < cuts.length; i++) { - text = text.slice(0, cuts[i][0]) + text.slice(cuts[i][0] + cuts[i][1]); - } - - return text; + return pairs; } /** - * 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. + * 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 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); + function deleteTags(cm, line, cuts, limit) { + var removed = 0; - for (;;) { - var opening = before.match(/$/); - var closing = after.match(/^<\/span>/); + cuts.sort(function (a, b) { + return b[0] - a[0]; + }); - if (!opening || !closing) { - break; - } + 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] }); - 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); + if (cuts[i][0] < limit) { + removed += cuts[i][1]; + } } - return { from: from, to: to }; + return removed; } /** * Try to wrap [start, end) of a line; true when the edit was made. */ - function tryWrap(editor, line, start, end, className, removeFirst) { + 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) { - body = unwrapSpans(body, 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, text.slice(0, start), body, text.slice(end))) { + if (!rendersCleanly(editor, context, line, text.slice(0, start), body, text.slice(end))) { return false; } - cm.replaceRange(openTag(className) + body + CLOSE, { line: line, ch: start }, { line: line, ch: end }); + 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 is - * marked inserted when an empty pair went in there. + * 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, line, parts, className, removeFirst) { + function wrapLine(editor, context, line, parts, className, removeFirst) { var cm = editor.codemirror; - if (isUntouchable(cm, line, cm.getLine(line))) { + if (isUntouchable(cm, line)) { return; } @@ -296,9 +444,11 @@ if (part.empty) { // Nothing selected: an empty pair to type into, if one fits. - if (rendersCleanly(editor, text.slice(0, part.start), '', text.slice(part.start))) { + if (isBlank(text)) { + insertParagraph(cm, line, className, part); + } else if (rendersCleanly(editor, context, line, text.slice(0, part.start), '', text.slice(part.start))) { cm.replaceRange(openTag(className) + CLOSE, { line: line, ch: part.start }); - part.inserted = true; + part.inside = cm.setBookmark({ line: line, ch: part.start + openTag(className).length }); } continue; @@ -306,7 +456,7 @@ var piece = trimmed(text, part.start, part.end); - if (piece === null || tryWrap(editor, line, piece.start, piece.end, className, removeFirst)) { + if (piece === null || tryWrap(editor, context, line, piece.start, piece.end, className, removeFirst)) { continue; } @@ -315,7 +465,7 @@ var whole = trimmed(text, 0, text.length); if (whole !== null) { - tryWrap(editor, line, whole.start, whole.end, className, removeFirst); + tryWrap(editor, context, line, whole.start, whole.end, className, removeFirst); } return; @@ -323,31 +473,146 @@ } /** - * Take spans off one line's parts. When a part holds half of a pair the - * whole line is cleaned instead: spans never reach across lines, so that - * is where the other half is. + * 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) { + 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; - for (var i = 0; i < parts.length; i++) { - var text = cm.getLine(line); - var start = parts[i].start; - var end = parts[i].end; - var slice = text.slice(start, end); + 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]; + }); + }; - if (count(slice, //g) !== count(slice, /<\/span>/g)) { - cm.replaceRange(unwrapSpans(text, isTarget), { line: line, ch: 0 }, { line: line, ch: text.length }); + 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; } - var cleaned = unwrapSpans(slice, isTarget); + 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 = []; - if (cleaned !== slice) { - cm.replaceRange(cleaned, { line: line, ch: start }, { line: line, ch: end }); + 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') : ''; } /** @@ -360,63 +625,72 @@ var cm = editor.codemirror; cm.operation(function () { - var selections = cm.listSelections().map(function (selection) { - var ordered = comparePositions(selection.anchor, selection.head) <= 0; - - return takeInSurroundingTags(cm, { - from: ordered ? selection.anchor : selection.head, - to: ordered ? selection.head : selection.anchor - }); - }); - + var context = { references: references(cm) }; var lines = {}; - var marks = []; - selections.forEach(function (range, index) { + var marks = mergedRanges(cm).map(function (range) { var empty = comparePositions(range.from, range.to) === 0; - - marks.push({ + 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, - inserted: false + inside: null }; - marks[index].parts.push(part); + 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(line, lines[line].sort(function (a, b) { + handler(context, line, lines[line].sort(function (a, b) { return b.start - a.start; })); }); - cm.setSelections(marks.map(function (mark) { + 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. - if (mark.empty && mark.parts[0].inserted) { - from = to = { line: to.line, ch: to.ch - CLOSE.length }; + 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; } - return { anchor: from, head: to }; - })); + selections.push(mark.reversed ? { anchor: to, head: from } : { anchor: from, head: to }); + }); + + if (selections.length > 0) { + cm.setSelections(selections); + } }); cm.focus(); @@ -446,21 +720,15 @@ var cm = editor.codemirror; // Pressed on underlined text: take the underline off. - var underlined = cm.listSelections().some(function (selection) { - var ordered = comparePositions(selection.anchor, selection.head) <= 0; - var range = takeInSurroundingTags(cm, { - from: ordered ? selection.anchor : selection.head, - to: ordered ? selection.head : selection.anchor - }); - + var underlined = mergedRanges(cm).some(function (range) { return cm.getRange(range.from, range.to).indexOf(openTag(styles.underline)) !== -1; }); - eachLine(editor, function (line, parts) { + eachLine(editor, function (context, line, parts) { if (underlined) { unwrapLine(editor, line, parts, isUnderline); } else { - wrapLine(editor, line, parts, styles.underline, null); + wrapLine(editor, context, line, parts, styles.underline, isUnderline); } }); } @@ -482,9 +750,9 @@ title: colour.label, icon: ' ' + escapeHtml(colour.label), action: function (editor) { - eachLine(editor, function (line, parts) { + eachLine(editor, function (context, line, parts) { // A new colour replaces the old one rather than nesting. - wrapLine(editor, line, parts, colour.class, isColour); + wrapLine(editor, context, line, parts, colour.class, isColour); }); } }; @@ -495,7 +763,7 @@ title: 'Remove colour', icon: ' Remove colour', action: function (editor) { - eachLine(editor, function (line, parts) { + eachLine(editor, function (context, line, parts) { unwrapLine(editor, line, parts, isColour); }); } @@ -533,7 +801,12 @@ var styles = readStyles(textarea); - new EasyMDE({ + // 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. @@ -561,10 +834,19 @@ // 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() { From ff78f5f2182ec8b1306b1f9cb30ba0b1d65ef4e5 Mon Sep 17 00:00:00 2001 From: paulsohier Date: Fri, 18 Sep 2026 12:13:47 +0100 Subject: [PATCH 5/5] Follow CommonMark's code spans and fences in the editor's styling From review, and from fuzzing against the site's renderer: * Code spans are found by CommonMark's own rule, the site's: a backtick run is closed by the next run of the same length. A span edge may not fall inside one, split a run of backticks, follow a backslash (which would escape the tag), or cross into or out of a link's or image's brackets. marked alone disagreed with the site on such odd input. * Fences are tracked by character and length, so a fence of one kind inside a block of another no longer confuses the rest of the page. * An indented paragraph is rendered together with the list item it continues, so it can be styled. * A blank line between list items or quote lines gets no empty pair: a paragraph there would split the list or quote. * A line that is only inline code is not taken for a code block. About 34,000 random edits with the reviewer's fuzzer, rendered through the site's SafeMarkdown, left no span tag showing as text. Co-Authored-By: Claude Opus 5 --- js/markdown-editor.js | 182 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 171 insertions(+), 11 deletions(-) diff --git a/js/markdown-editor.js b/js/markdown-editor.js index d0243dc..cf3af26 100644 --- a/js/markdown-editor.js +++ b/js/markdown-editor.js @@ -141,6 +141,66 @@ 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. */ @@ -167,11 +227,35 @@ * 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; - while (first > 0 && !isBlank(cm.getLine(first - 1))) { - first--; + // 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))) { @@ -179,12 +263,17 @@ } 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 lines.join('\n') + context.references; + return { text: lines.join('\n'), offset: offset }; } /** @@ -201,6 +290,35 @@ 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)); @@ -239,7 +357,9 @@ continue; } - if (!/\bcomment\b/.test(type)) { + // 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; } @@ -250,19 +370,33 @@ } /** - * Whether a line lies between the fences of a fenced code block: an odd - * number of fences above it. + * Whether a line lies between the fences of a fenced code block. */ function isInsideFence(cm, line) { - var fences = 0; + var open = null; for (var i = 0; i < line; i++) { - if (FENCE.test(withoutPrefix(cm.getLine(i)))) { - fences++; + 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 fences % 2 === 1; + return open !== null; } function isUntouchable(cm, line) { @@ -446,7 +580,11 @@ // Nothing selected: an empty pair to type into, if one fits. if (isBlank(text)) { insertParagraph(cm, line, className, part); - } else if (rendersCleanly(editor, context, line, text.slice(0, part.start), '', text.slice(part.start))) { + } 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 }); } @@ -472,12 +610,34 @@ } } + /** + * 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' : '';