FEATURE: add code-block rich editor extension (#31179)

Continues the work done on
https://github.com/discourse/discourse/pull/30815.

Extends the ProseMirror-markdown `code-block` node by integrating our
existing HighlightJS pipeline for code highlighting and adding a node
view with a `<select>` to change the language of the block.

We're also adding the markdown paste extension, which handles converting
pasted text/plain to rich content if it contains Markdown.

---------

Co-authored-by: Martin Brennan <martin@discourse.org>
This commit is contained in:
Renato Atilio 2025-03-06 20:43:33 -03:00 committed by GitHub
parent 991379b9a4
commit a5cacde681
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 304 additions and 126 deletions

View File

@ -58,7 +58,7 @@ export default async function highlightSyntax(elem, siteSettings, session) {
});
}
async function ensureHighlightJs(langFile) {
export async function ensureHighlightJs(langFile) {
try {
if (!hljsLoadPromise) {
hljsLoadPromise = loadHighlightJs(langFile);

View File

@ -73,7 +73,7 @@ export default class ProsemirrorEditor extends Component {
get pluginParams() {
return {
utils,
utils: { ...utils, convertFromMarkdown: this.convertFromMarkdown },
schema: this.schema,
pmState: ProsemirrorState,
pmModel: ProsemirrorModel,

View File

@ -0,0 +1,137 @@
import { highlightPlugin } from "prosemirror-highlightjs";
import { ensureHighlightJs } from "discourse/lib/highlight-syntax";
// cached hljs instance with custom plugins/languages
let hljs;
class CodeBlockWithLangSelectorNodeView {
#selectAdded = false;
constructor(node, view, getPos) {
this.node = node;
this.view = view;
this.getPos = getPos;
const code = document.createElement("code");
const pre = document.createElement("pre");
pre.appendChild(code);
this.dom = pre;
this.contentDOM = code;
this.appendSelect();
}
changeListener(e) {
this.view.dispatch(
this.view.state.tr.setNodeMarkup(this.getPos(), null, {
params: e.target.value,
})
);
if (e.target.firstChild.textContent) {
e.target.firstChild.textContent = "";
}
}
appendSelect() {
if (!hljs || this.#selectAdded) {
return;
}
this.#selectAdded = true;
const select = document.createElement("select");
select.contentEditable = false;
select.addEventListener("change", (e) => this.changeListener(e));
select.classList.add("code-language-select");
const languages = hljs.listLanguages();
const empty = document.createElement("option");
empty.textContent = languages.includes(this.node.attrs.params)
? ""
: this.node.attrs.params;
select.appendChild(empty);
languages.forEach((lang) => {
const option = document.createElement("option");
option.textContent = lang;
option.selected = lang === this.node.attrs.params;
select.appendChild(option);
});
this.dom.appendChild(select);
}
update(node) {
this.appendSelect();
return node.type === this.node.type;
}
destroy() {
this.dom.removeEventListener("change", (e) => this.changeListener(e));
}
}
/** @type {RichEditorExtension} */
const extension = {
nodeViews: { code_block: CodeBlockWithLangSelectorNodeView },
plugins({ pmState: { Plugin }, getContext }) {
return [
async () =>
highlightPlugin(
(hljs = await ensureHighlightJs(
getContext().session.highlightJsPath
)),
["code_block", "html_block"],
// NOTE: If the language has not been set with the code block, we default to plain
// text rather than autodetecting. This is to work around an infinite loop issue
// in prosemirror-highlightjs when autodetecting which hangs the browser sometimes
// for > 10 seconds, for example:
//
// https://github.com/b-kelly/prosemirror-highlightjs/issues/21
//
// We can remove this if we find some other workaround.
(node) => node.attrs.params || "text"
),
new Plugin({
props: {
// Handles removal of the code_block when it's at the start of the document
handleKeyDown(view, event) {
if (
event.key === "Backspace" &&
view.state.selection.$from.parent.type ===
view.state.schema.nodes.code_block &&
view.state.selection.$from.start() === 1 &&
view.state.selection.$from.parentOffset === 0
) {
const { tr } = view.state;
const codeBlock = view.state.selection.$from.parent;
const paragraph = view.state.schema.nodes.paragraph.create(
null,
codeBlock.content
);
tr.replaceWith(
view.state.selection.$from.before(),
view.state.selection.$from.after(),
paragraph
);
tr.setSelection(
new view.state.selection.constructor(tr.doc.resolve(1))
);
view.dispatch(tr);
return true;
}
},
},
}),
];
},
};
export default extension;

View File

@ -0,0 +1,20 @@
/** @type {RichEditorExtension} */
const extension = {
plugins({
pmState: { Plugin },
pmModel: { Fragment, Slice },
utils: { convertFromMarkdown },
}) {
return new Plugin({
props: {
clipboardTextParser(text) {
const { content } = convertFromMarkdown(text);
return Slice.maxOpen(Fragment.from(content));
},
},
});
},
};
export default extension;

View File

@ -1,9 +1,11 @@
import { registerRichEditorExtension } from "discourse/lib/composer/rich-editor-extensions";
import codeBlock from "./code-block";
import emoji from "./emoji";
import hashtag from "./hashtag";
import heading from "./heading";
import image from "./image";
import link from "./link";
import markdownPaste from "./markdown-paste";
import mention from "./mention";
import quote from "./quote";
import strikethrough from "./strikethrough";
@ -21,12 +23,14 @@ const defaultExtensions = [
image,
link,
heading,
codeBlock,
quote,
hashtag,
mention,
strikethrough,
underline,
table,
markdownPaste,
];
defaultExtensions.forEach(registerRichEditorExtension);

View File

@ -25,10 +25,15 @@ export async function testMarkdown(
/>
</template>
);
// ensure toggling to rich editor and back works
await click(".composer-toggle-switch");
await click(".composer-toggle-switch");
await click(".composer-toggle-switch");
await waitFor(".ProseMirror");
await settled();
const editor = document.querySelector(".ProseMirror");
// typeIn for contentEditable isn't reliable, and is slower

View File

@ -0,0 +1,58 @@
import { module, test } from "qunit";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import { testMarkdown } from "discourse/tests/helpers/rich-editor-helper";
module(
"Integration | Component | prosemirror-editor - code-block extension",
function (hooks) {
setupRenderingTest(hooks);
hooks.beforeEach(function () {
this.siteSettings.rich_editor = true;
});
const select = (lang = "") =>
`<select contenteditable="false" class="code-language-select"><option>${lang}</option><option>javascript</option><option>ruby</option><option>sql</option></select>`;
Object.entries({
"basic code block": [
"```plaintext\nconsole.log('Hello, world!');\n```",
`<pre><code>console.log('Hello, world!');</code>${select(
"plaintext"
)}</pre>`,
"```plaintext\nconsole.log('Hello, world!');\n```",
],
"basic code block without a lanuage": [
"```\nconsole.log('Hello, world!');\n```",
`<pre><code>console.log('Hello, world!');</code>${select()}</pre>`,
"```\nconsole.log('Hello, world!');\n```",
],
"code block within list item": [
"- ```plaintext\n console.log('Hello, world!');\n ```",
`<ul><li><pre><code>console.log('Hello, world!');</code>${select(
"plaintext"
)}</pre></li></ul>`,
"* ```plaintext\n console.log('Hello, world!');\n ```",
],
"code block with language": [
'```javascript\nconsole.log("Hello, world!");\n```',
`<pre><code><span class="hljs-variable language_">console</span>.<span class="hljs-title function_">log</span>(<span class="hljs-string">"Hello, world!"</span>);</code>${select()}</pre>`,
'```javascript\nconsole.log("Hello, world!");\n```',
],
"code block with 4 spaces": [
" print('Hello, world!')",
`<pre><code>print('Hello, world!')</code>${select()}</pre>`,
"```\nprint('Hello, world!')\n```",
],
"code block with 4 spaces within list item": [
"- print('Hello, world!')",
`<ul><li><pre><code>print('Hello, world!')</code>${select()}</pre></li></ul>`,
"* ```\n print('Hello, world!')\n ```",
],
}).forEach(([name, [markdown, html, expectedMarkdown]]) => {
test(name, async function (assert) {
await testMarkdown(assert, markdown, html, expectedMarkdown);
});
});
}
);

View File

@ -12,13 +12,8 @@ import { testMarkdown } from "discourse/tests/helpers/rich-editor-helper";
module("Integration | Component | prosemirror-editor", function (hooks) {
setupRenderingTest(hooks);
hooks.beforeEach(function () {
clearRichEditorExtensions();
});
hooks.afterEach(function () {
resetRichEditorExtensions();
});
hooks.beforeEach(() => clearRichEditorExtensions());
hooks.afterEach(() => resetRichEditorExtensions());
test("renders the editor", async function (assert) {
await render(<template><ProsemirrorEditor /></template>);

View File

@ -1,4 +1,8 @@
import { module, test } from "qunit";
import {
clearRichEditorExtensions,
resetRichEditorExtensions,
} from "discourse/lib/composer/rich-editor-extensions";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import { testMarkdown } from "discourse/tests/helpers/rich-editor-helper";
@ -7,6 +11,9 @@ module(
function (hooks) {
setupRenderingTest(hooks);
hooks.beforeEach(() => clearRichEditorExtensions());
hooks.afterEach(() => resetRichEditorExtensions());
const testCases = {
"paragraphs and hard breaks": [
["Hello", "<p>Hello</p>", "Hello"],

View File

@ -61,6 +61,12 @@ h6 code {
color: var(--hljs-number);
}
.hljs-tag,
.hljs-tag .hljs-title {
color: var(--hljs-tag);
font-weight: normal;
}
.hljs-string,
.hljs-tag .hljs-string,
.hljs-template-tag,
@ -73,10 +79,6 @@ h6 code {
color: var(--hljs-title);
}
.hljs-name {
color: var(--hljs-name);
}
.hljs-quote,
.hljs-operator,
.hljs-selector-pseudo,
@ -94,10 +96,8 @@ h6 code {
color: var(--hljs-title);
}
.hljs-tag,
.hljs-tag .hljs-title {
color: var(--hljs-tag);
font-weight: normal;
.hljs-name {
color: var(--hljs-name);
}
.hljs-punctuation {

View File

@ -134,10 +134,6 @@
white-space: normal;
}
.code-block {
position: relative;
}
.code-language-select {
position: absolute;
right: 0.25rem;
@ -149,8 +145,11 @@
font-size: var(--font-down-1-rem);
}
.html-block {
pre {
position: relative;
}
.html-block {
border: 1px dashed var(--primary-low-mid);
&::after {
@ -169,13 +168,14 @@
Section below from prosemirror-view/style/prosemirror.css
********************************************************/
/* stylelint-disable-next-line no-duplicate-selectors */
// stylelint-disable-next-line no-duplicate-selectors
.ProseMirror {
position: relative;
word-wrap: break-word;
white-space: break-spaces;
}
// stylelint-disable-next-line no-duplicate-selectors
.ProseMirror pre {
white-space: pre-wrap;
}

View File

@ -3,6 +3,7 @@
describe "Composer - ProseMirror editor", type: :system do
fab!(:user) { Fabricate(:user, refresh_auto_groups: true) }
fab!(:tag)
let(:cdp) { PageObjects::CDP.new }
let(:composer) { PageObjects::Components::Composer.new }
let(:rich) { composer.rich_editor }
@ -11,6 +12,12 @@ describe "Composer - ProseMirror editor", type: :system do
SiteSetting.rich_editor = true
end
def open_composer_and_toggle_rich_editor
page.visit "/new-topic"
expect(composer).to be_opened
composer.toggle_rich_editor
end
it "hides the Composer container's preview button" do
page.visit "/new-topic"
@ -24,34 +31,21 @@ describe "Composer - ProseMirror editor", type: :system do
context "with autocomplete" do
it "triggers an autocomplete on mention" do
page.visit "/new-topic"
expect(composer).to be_opened
composer.toggle_rich_editor
open_composer_and_toggle_rich_editor
composer.type_content("@#{user.username}")
expect(composer).to have_mention_autocomplete
end
it "triggers an autocomplete on hashtag" do
page.visit "/new-topic"
expect(composer).to be_opened
find(".composer-toggle-switch").click
open_composer_and_toggle_rich_editor
composer.type_content("##{tag.name}")
expect(composer).to have_hashtag_autocomplete
end
it "triggers an autocomplete on emoji" do
page.visit "/new-topic"
expect(composer).to be_opened
composer.toggle_rich_editor
open_composer_and_toggle_rich_editor
composer.type_content(":smile")
expect(composer).to have_emoji_autocomplete
@ -60,22 +54,14 @@ describe "Composer - ProseMirror editor", type: :system do
context "with inputRules" do
it "supports > to create a blockquote" do
page.visit "/new-topic"
expect(composer).to be_opened
composer.toggle_rich_editor
open_composer_and_toggle_rich_editor
composer.type_content("> This is a blockquote")
expect(rich).to have_css("blockquote", text: "This is a blockquote")
end
it "supports n. to create an ordered list" do
page.visit "/new-topic"
expect(composer).to be_opened
composer.toggle_rich_editor
open_composer_and_toggle_rich_editor
composer.type_content("1. Item 1\n5. Item 2")
expect(rich).to have_css("ol li", text: "Item 1")
@ -83,11 +69,7 @@ describe "Composer - ProseMirror editor", type: :system do
end
it "supports *, - or + to create an unordered list" do
page.visit "/new-topic"
expect(composer).to be_opened
composer.toggle_rich_editor
open_composer_and_toggle_rich_editor
composer.type_content("* Item 1\n")
composer.type_content("- Item 2\n")
composer.type_content("+ Item 3")
@ -96,11 +78,7 @@ describe "Composer - ProseMirror editor", type: :system do
end
it "supports ``` or 4 spaces to create a code block" do
page.visit "/new-topic"
expect(composer).to be_opened
composer.toggle_rich_editor
open_composer_and_toggle_rich_editor
composer.type_content("```\nThis is a code block")
composer.send_keys(%i[shift enter])
composer.type_content(" This is a code block")
@ -109,11 +87,7 @@ describe "Composer - ProseMirror editor", type: :system do
end
it "supports 1-6 #s to create a heading" do
page.visit "/new-topic"
expect(composer).to be_opened
composer.toggle_rich_editor
open_composer_and_toggle_rich_editor
composer.type_content("# Heading 1\n")
composer.type_content("## Heading 2\n")
composer.type_content("### Heading 3\n")
@ -130,11 +104,7 @@ describe "Composer - ProseMirror editor", type: :system do
end
it "supports _ or * to create an italic text" do
page.visit "/new-topic"
expect(composer).to be_opened
composer.toggle_rich_editor
open_composer_and_toggle_rich_editor
composer.type_content("_This is italic_\n")
composer.type_content("*This is italic*")
@ -142,11 +112,7 @@ describe "Composer - ProseMirror editor", type: :system do
end
it "supports __ or ** to create a bold text" do
page.visit "/new-topic"
expect(composer).to be_opened
composer.toggle_rich_editor
open_composer_and_toggle_rich_editor
composer.type_content("__This is bold__\n")
composer.type_content("**This is bold**")
@ -154,11 +120,7 @@ describe "Composer - ProseMirror editor", type: :system do
end
it "supports ` to create a code text" do
page.visit "/new-topic"
expect(composer).to be_opened
composer.toggle_rich_editor
open_composer_and_toggle_rich_editor
composer.type_content("`This is code`")
expect(rich).to have_css("code", text: "This is code")
@ -168,11 +130,7 @@ describe "Composer - ProseMirror editor", type: :system do
context "with keymap" do
PLATFORM_KEY_MODIFIER = SystemHelpers::PLATFORM_KEY_MODIFIER
it "supports Ctrl + B to create a bold text" do
page.visit "/new-topic"
expect(composer).to be_opened
composer.toggle_rich_editor
open_composer_and_toggle_rich_editor
composer.type_content([PLATFORM_KEY_MODIFIER, "b"])
composer.type_content("This is bold")
@ -180,11 +138,7 @@ describe "Composer - ProseMirror editor", type: :system do
end
it "supports Ctrl + I to create an italic text" do
page.visit "/new-topic"
expect(composer).to be_opened
composer.toggle_rich_editor
open_composer_and_toggle_rich_editor
composer.type_content([PLATFORM_KEY_MODIFIER, "i"])
composer.type_content("This is italic")
@ -192,12 +146,7 @@ describe "Composer - ProseMirror editor", type: :system do
end
xit "supports Ctrl + K to create a link" do
page.visit "/new-topic"
expect(composer).to be_opened
composer.toggle_rich_editor
page.send_keys([PLATFORM_KEY_MODIFIER, "k"])
open_composer_and_toggle_rich_editor page.send_keys([PLATFORM_KEY_MODIFIER, "k"])
page.send_keys("https://www.example.com\t")
page.send_keys("This is a link")
page.send_keys(:enter)
@ -206,11 +155,7 @@ describe "Composer - ProseMirror editor", type: :system do
end
it "supports Ctrl + Shift + 7 to create an ordered list" do
page.visit "/new-topic"
expect(composer).to be_opened
composer.toggle_rich_editor
open_composer_and_toggle_rich_editor
composer.type_content("Item 1")
composer.send_keys([PLATFORM_KEY_MODIFIER, :shift, "7"])
@ -218,11 +163,7 @@ describe "Composer - ProseMirror editor", type: :system do
end
it "supports Ctrl + Shift + 8 to create a bullet list" do
page.visit "/new-topic"
expect(composer).to be_opened
composer.toggle_rich_editor
open_composer_and_toggle_rich_editor
composer.type_content("Item 1")
composer.send_keys([PLATFORM_KEY_MODIFIER, :shift, "8"])
@ -230,11 +171,7 @@ describe "Composer - ProseMirror editor", type: :system do
end
it "supports Ctrl + Shift + 9 to create a blockquote" do
page.visit "/new-topic"
expect(composer).to be_opened
composer.toggle_rich_editor
open_composer_and_toggle_rich_editor
composer.type_content("This is a blockquote")
composer.send_keys([PLATFORM_KEY_MODIFIER, :shift, "9"])
@ -242,12 +179,7 @@ describe "Composer - ProseMirror editor", type: :system do
end
it "supports Ctrl + Shift + 1-6 for headings, 0 for reset" do
page.visit "/new-topic"
expect(composer).to be_opened
composer.toggle_rich_editor
open_composer_and_toggle_rich_editor
(1..6).each do |i|
composer.type_content("\nHeading #{i}")
composer.send_keys([PLATFORM_KEY_MODIFIER, :shift, i.to_s])
@ -260,11 +192,7 @@ describe "Composer - ProseMirror editor", type: :system do
end
it "supports Ctrl + Z and Ctrl + Shift + Z to undo and redo" do
page.visit "/new-topic"
expect(composer).to be_opened
composer.toggle_rich_editor
open_composer_and_toggle_rich_editor
composer.type_content("This is a test")
composer.send_keys([PLATFORM_KEY_MODIFIER, "z"])
@ -276,15 +204,32 @@ describe "Composer - ProseMirror editor", type: :system do
end
it "supports Ctrl + Shift + _ to create a horizontal rule" do
page.visit "/new-topic"
expect(composer).to be_opened
composer.toggle_rich_editor
open_composer_and_toggle_rich_editor
composer.type_content("This is a test")
composer.send_keys([PLATFORM_KEY_MODIFIER, :shift, "_"])
expect(rich).to have_css("hr")
end
end
describe "pasting content" do
it "does not freeze the editor when pasting markdown code blocks without a language" do
cdp.allow_clipboard
open_composer_and_toggle_rich_editor
# The example is a bit convoluted, but it's the simplest way to reproduce the issue.
cdp.write_clipboard <<~MARKDOWN
```
puts SiteSetting.all_settings(filter_categories: ["uncategorized"]).map { |setting| setting[:setting] }.join("\n")
```
MARKDOWN
composer.type_content("This is a test\n\n")
page.send_keys([PLATFORM_KEY_MODIFIER, "v"])
expect(page.driver.browser.logs.get(:browser)).not_to include(
"Maximum call stack size exceeded",
)
expect(rich).to have_css("pre code", wait: 1)
expect(rich).to have_css("select.code-language-select", wait: 1)
end
end
end

View File

@ -30,6 +30,13 @@ module PageObjects
page.evaluate_async_script("navigator.clipboard.readText().then(arguments[0])")
end
def write_clipboard(text)
page.evaluate_async_script(
"navigator.clipboard.writeText(arguments[0]).then(arguments[1])",
text,
)
end
def clipboard_has_text?(text, chomp: false, strict: true)
try_until_success do
clipboard_text = chomp ? read_clipboard.chomp : read_clipboard