Skip to content

Add colour and underline buttons to the Markdown editor - #34

Merged
paul999 merged 5 commits into
masterfrom
issue-19/editor-colour-underline
Sep 18, 2026
Merged

paul999 merged 5 commits into
masterfrom
issue-19/editor-colour-underline

Conversation

@paul999

@paul999 paul999 commented Sep 18, 2026

Copy link
Copy Markdown
Member

Colour and underline buttons for the Markdown editor, so new knowledge base articles can use the styling migrated articles keep (issue #19 in paul999/phpbb-website-private). Follows #33, which added the CSS classes.

What authors get

  • U (Ctrl/Cmd+U) underlines the selection, or takes the underline off again, also under a colour.
  • A opens a menu with one entry per palette colour, each shown in its own colour, plus "Remove colour". A new colour replaces the old one rather than nesting.
  • It works with several cursors, and the selection stays on the styled words, so red then underline acts on the same text.
  • The buttons only appear when the website asks for them (data-markdown-styles on the textarea, from MarkdownEditorType's text_styles option). The list of classes comes from the website, from the same constants its renderer checks, so this script keeps no copy of the palette. Team blurbs don't get the buttons, because the team page doesn't style these classes.

Why the JS is bigger than two buttons

The website keeps colour and underline as <span class="text-…">. SafeMarkdown renders such a span only when it pairs up within one block without crossing other formatting, and leaves it off the page otherwise (phpbb/phpbb-website-private#222). So the buttons try every edit before making it:

  • They work line by line, and leave code blocks (in quotes and list items too), fences, tables, rules, setext underlines and reference definitions alone. List, heading and quote markers stay outside the span.
  • Each edit is tried first: the paragraph is rendered with a probe span and without it, using EasyMDE's own renderer. marked's raw HTML is taken through the sanitizerFunction hook, unchanged, so the preview is unaffected. The edit is made only if:
    • the probe holds properly nested inline content with no block in it,
    • taking the probe out gives back exactly the original rendering, and
    • by CommonMark's own rules (the site's), neither edge falls inside inline code, splits a backtick run, follows a backslash or crosses a link's brackets.
  • If the selected part fails, the line's whole text is tried once. If that fails too, the line is left alone.

Testing

  • The whole path works end to end on the website's Markdown demo page: the buttons, the editor preview and the server render.
  • Four review rounds, using a puppeteer harness that drives the real EasyMDE and renders every result through the site's SafeMarkdown:
    • 202 cases of realistic content and edge cases all come out right, including headings, lists, quotes, code, links, reference links, hard breaks, multiple cursors and recolouring.
    • About 34,000 randomly generated edits left no span tag showing as HTML text and caused no page errors.
    • What still gets flagged is delimiter soup (like ****]***) where marked and CommonMark disagree about emphasis. On the site the worst outcome there is a dropped colour.

After this merges, composer update phpbb/website-assets goes into phpbb/phpbb-website-private#222.

🤖 Generated with Claude Code

paul999 and others added 5 commits September 18, 2026 11:26
Markdown has neither, and the website keeps both as
<span class="text-…"> 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings September 18, 2026 11:14
@paul999
paul999 merged commit 425b51b into master Sep 18, 2026
1 check passed

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The JavaScript contains a critical sanitizer issue and several moderate Markdown-editing issues that must be addressed.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Adds optional colour and underline controls to the EasyMDE Markdown editor, with Markdown validation and new toolbar styling.

Changes:

  • Adds data-driven colour and underline editing.
  • Adds selection handling, shortcuts, and rendering validation.
  • Styles the new toolbar controls and colour menu.
File summaries
File Review findings
js/markdown-editor.js Critical, 1 vote (line 98): The sanitizer hook handling prevents probe detection and causes edits to be rejected.
Moderate, 1 vote (lines 413, 419, 433, 543, 741, 996): Preserve default shortcuts; avoid treating escaped/code text as style spans; handle even backslash runs in tables; detect headings after list/quote prefixes; correctly identify reference continuations; and expand containing spans for partial selections.
css/markdown-editor.css No final review comments.
Review details

Suppressed comments (6)

js/markdown-editor.js:996

  • EasyMDE treats shortcuts as the complete shortcut map, so this replaces its default bindings: editors without styles get {}, and styled editors retain only underline. That disables the existing Cmd/Ctrl-B, Cmd/Ctrl-I, Cmd/Ctrl-K and other built-in keyboard shortcuts on every editor. Preserve the default map and add the underline binding instead.
			shortcuts: styles && styles.underline ? { underline: 'Cmd-U' } : {},

js/markdown-editor.js:547

  • spanPairs parses raw Markdown rather than rendered HTML, so tag-shaped text inside an inline code span or escaped HTML is treated as an existing style. Recolouring such a selection removes those literal <span ...> and </span> characters before rendersCleanly runs; the probe then compares the already-mutated body and accepts the edit, silently changing the code/text. Only remove pairs that are actual rendered style spans, excluding code and escaped text.
			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]);
			});

js/markdown-editor.js:420

  • This table check treats a pipe as escaped whenever the immediately preceding character is a backslash. In CommonMark an even run is not an escape, so a row such as a\\|b can be parsed as a table while this code styles it, contrary to the stated rule that table rows remain untouched. Count consecutive backslashes modulo two.
		if (/(^|[^\\])\|/.test(text.replace(/(`+)[^`]*?\1/g, ''))) {
			return true;

js/markdown-editor.js:439

  • The closing-hash check only recognizes headings that start directly with #. For > # Heading # or - # Heading #, BLOCK_PREFIX removes the quote/list marker but this check fails, so a whole-line selection includes the closing hashes in the probe; the render equality check then rejects an otherwise valid edit. Detect an ATX heading after optional list/quote prefixes so the closing hashes remain outside the span.
		if (/^ {0,3}#{1,6}(\s|$)/.test(text)) {
			var closing = text.match(/\s+#+\s*$/);

			if (closing) {
				end = closing.index;
			}
		}

js/markdown-editor.js:415

  • This treats every nonblank line immediately after a reference definition as a reference continuation. A valid document such as [id]: /url followed directly by Paragraph makes that paragraph ordinary Markdown, but this condition marks it untouchable so it can never be styled or unstyled. Restrict the check to a syntactically valid continuation title (or parse the definition and continuation together).
		if (line > 0 && REFERENCE.test(cm.getLine(line - 1)) && !isBlank(text)) {
			return true;
		}

js/markdown-editor.js:750

  • This only expands a selection when the opening and closing tags are immediately adjacent to its edges. Selecting a proper subset of text inside an existing colour span therefore leaves the selection inside the old span: recolouring nests the new span instead of replacing the old one, and “Remove colour” finds no tag overlapping the selection and does nothing. Expand/split containing target spans before applying the edit so partial selections have the same replace/remove behavior as selections restored by the toolbar.
		for (;;) {
			var opening = before.match(/<span class="[^"<>]*">$/);
			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 };
  • Files reviewed: 2/2 changed files
  • Comments generated: 1
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread js/markdown-editor.js
var html = editor.markdown(markdown);

return {
raw: editor.markdownCapture.raw,
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants