diff --git a/app/assets/javascripts/discourse/app/lib/highlight-syntax.js b/app/assets/javascripts/discourse/app/lib/highlight-syntax.js index 55300c540a0..e0941e80110 100644 --- a/app/assets/javascripts/discourse/app/lib/highlight-syntax.js +++ b/app/assets/javascripts/discourse/app/lib/highlight-syntax.js @@ -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); diff --git a/app/assets/javascripts/discourse/app/static/prosemirror/components/prosemirror-editor.gjs b/app/assets/javascripts/discourse/app/static/prosemirror/components/prosemirror-editor.gjs index 91b5578f340..d17e3ee739e 100644 --- a/app/assets/javascripts/discourse/app/static/prosemirror/components/prosemirror-editor.gjs +++ b/app/assets/javascripts/discourse/app/static/prosemirror/components/prosemirror-editor.gjs @@ -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, diff --git a/app/assets/javascripts/discourse/app/static/prosemirror/extensions/code-block.js b/app/assets/javascripts/discourse/app/static/prosemirror/extensions/code-block.js new file mode 100644 index 00000000000..d120271d7a8 --- /dev/null +++ b/app/assets/javascripts/discourse/app/static/prosemirror/extensions/code-block.js @@ -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; diff --git a/app/assets/javascripts/discourse/app/static/prosemirror/extensions/markdown-paste.js b/app/assets/javascripts/discourse/app/static/prosemirror/extensions/markdown-paste.js new file mode 100644 index 00000000000..9469ff58823 --- /dev/null +++ b/app/assets/javascripts/discourse/app/static/prosemirror/extensions/markdown-paste.js @@ -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; diff --git a/app/assets/javascripts/discourse/app/static/prosemirror/extensions/register-default.js b/app/assets/javascripts/discourse/app/static/prosemirror/extensions/register-default.js index a7d689b891d..2abd4a799df 100644 --- a/app/assets/javascripts/discourse/app/static/prosemirror/extensions/register-default.js +++ b/app/assets/javascripts/discourse/app/static/prosemirror/extensions/register-default.js @@ -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); diff --git a/app/assets/javascripts/discourse/tests/helpers/rich-editor-helper.gjs b/app/assets/javascripts/discourse/tests/helpers/rich-editor-helper.gjs index 05ee0f6360b..c378cfa1769 100644 --- a/app/assets/javascripts/discourse/tests/helpers/rich-editor-helper.gjs +++ b/app/assets/javascripts/discourse/tests/helpers/rich-editor-helper.gjs @@ -25,10 +25,15 @@ export async function testMarkdown( /> ); + + // 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 diff --git a/app/assets/javascripts/discourse/tests/integration/components/prosemirror-editor/code-block-test.gjs b/app/assets/javascripts/discourse/tests/integration/components/prosemirror-editor/code-block-test.gjs new file mode 100644 index 00000000000..11743fab9b7 --- /dev/null +++ b/app/assets/javascripts/discourse/tests/integration/components/prosemirror-editor/code-block-test.gjs @@ -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 = "") => + ``; + + Object.entries({ + "basic code block": [ + "```plaintext\nconsole.log('Hello, world!');\n```", + `
console.log('Hello, world!');${select(
+          "plaintext"
+        )}
`, + "```plaintext\nconsole.log('Hello, world!');\n```", + ], + "basic code block without a lanuage": [ + "```\nconsole.log('Hello, world!');\n```", + `
console.log('Hello, world!');${select()}
`, + "```\nconsole.log('Hello, world!');\n```", + ], + "code block within list item": [ + "- ```plaintext\n console.log('Hello, world!');\n ```", + ``, + "* ```plaintext\n console.log('Hello, world!');\n ```", + ], + "code block with language": [ + '```javascript\nconsole.log("Hello, world!");\n```', + `
console.log("Hello, world!");${select()}
`, + '```javascript\nconsole.log("Hello, world!");\n```', + ], + "code block with 4 spaces": [ + " print('Hello, world!')", + `
print('Hello, world!')${select()}
`, + "```\nprint('Hello, world!')\n```", + ], + "code block with 4 spaces within list item": [ + "- print('Hello, world!')", + ``, + "* ```\n print('Hello, world!')\n ```", + ], + }).forEach(([name, [markdown, html, expectedMarkdown]]) => { + test(name, async function (assert) { + await testMarkdown(assert, markdown, html, expectedMarkdown); + }); + }); + } +); diff --git a/app/assets/javascripts/discourse/tests/integration/components/prosemirror-editor/prosemirror-editor-test.gjs b/app/assets/javascripts/discourse/tests/integration/components/prosemirror-editor/prosemirror-editor-test.gjs index 10f32e83f44..131aa850447 100644 --- a/app/assets/javascripts/discourse/tests/integration/components/prosemirror-editor/prosemirror-editor-test.gjs +++ b/app/assets/javascripts/discourse/tests/integration/components/prosemirror-editor/prosemirror-editor-test.gjs @@ -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(); diff --git a/app/assets/javascripts/discourse/tests/integration/components/prosemirror-editor/prosemirror-markdown-test.gjs b/app/assets/javascripts/discourse/tests/integration/components/prosemirror-editor/prosemirror-markdown-test.gjs index eeb54849e21..d828493e176 100644 --- a/app/assets/javascripts/discourse/tests/integration/components/prosemirror-editor/prosemirror-markdown-test.gjs +++ b/app/assets/javascripts/discourse/tests/integration/components/prosemirror-editor/prosemirror-markdown-test.gjs @@ -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", "

Hello

", "Hello"], diff --git a/app/assets/stylesheets/common/base/code_highlighting.scss b/app/assets/stylesheets/common/base/code_highlighting.scss index 0b8e75f8ab5..bf1a6cbe8fe 100644 --- a/app/assets/stylesheets/common/base/code_highlighting.scss +++ b/app/assets/stylesheets/common/base/code_highlighting.scss @@ -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 { diff --git a/app/assets/stylesheets/common/rich-editor/rich-editor.scss b/app/assets/stylesheets/common/rich-editor/rich-editor.scss index 37caeae953a..06d18aa9257 100644 --- a/app/assets/stylesheets/common/rich-editor/rich-editor.scss +++ b/app/assets/stylesheets/common/rich-editor/rich-editor.scss @@ -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; } diff --git a/spec/system/composer/prosemirror_editor_spec.rb b/spec/system/composer/prosemirror_editor_spec.rb index 64012db13c3..10af16c91bd 100644 --- a/spec/system/composer/prosemirror_editor_spec.rb +++ b/spec/system/composer/prosemirror_editor_spec.rb @@ -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 diff --git a/spec/system/page_objects/cdp.rb b/spec/system/page_objects/cdp.rb index a21af946073..512817369d4 100644 --- a/spec/system/page_objects/cdp.rb +++ b/spec/system/page_objects/cdp.rb @@ -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