FEATURE: add code-block rich editor extension

This commit is contained in:
Renato Atilio
2025-02-04 17:25:11 -03:00
parent 0e61565b2b
commit a1bc7c9ce7
5 changed files with 186 additions and 2 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

@@ -0,0 +1,128 @@
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);
pre.classList.add("code-block");
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"]
),
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

@@ -1,4 +1,5 @@
import { registerRichEditorExtension } from "discourse/lib/composer/rich-editor-extensions";
import codeBlock from "./code-block";
/**
* List of default extensions
@@ -6,6 +7,6 @@ import { registerRichEditorExtension } from "discourse/lib/composer/rich-editor-
*
* @type {RichEditorExtension[]}
*/
const defaultExtensions = [];
const defaultExtensions = [codeBlock];
defaultExtensions.forEach(registerRichEditorExtension);

View File

@@ -23,10 +23,15 @@ export async function testMarkdown(
@onSetup={{handleSetup}}
/>
</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,50 @@
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);
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 class="code-block"><code>console.log('Hello, world!');</code>${select(
"plaintext"
)}</pre>`,
"```plaintext\nconsole.log('Hello, world!');\n```",
],
"code block within list item": [
"- ```plaintext\n console.log('Hello, world!');\n ```",
`<ul><li><pre class="code-block"><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 class="code-block"><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 class="code-block"><code><span class="hljs-title function_">print</span>(<span class="hljs-string">'Hello, world!'</span>)</code>${select()}</pre>`,
"```\nprint('Hello, world!')\n```",
],
"code block with 4 spaces within list item": [
"- print('Hello, world!')",
`<ul><li><pre class="code-block"><code><span class="hljs-title function_">print</span>(<span class="hljs-string">\'Hello, world!\'</span>)</code>${select()}</pre></li></ul>`,
"* ```\n print('Hello, world!')\n ```",
],
}).forEach(([name, [markdown, html, expectedMarkdown]]) => {
test(name, async function (assert) {
this.siteSettings.rich_editor = true;
await testMarkdown(assert, markdown, html, expectedMarkdown);
});
});
}
);