From 9a6d6f55df5b7297d46621f1364b161ca243a744 Mon Sep 17 00:00:00 2001 From: Renato Atilio Date: Mon, 17 Aug 2026 15:06:36 -0300 Subject: [PATCH 1/2] FIX: Ignore styles the browser inlines on copied rich content --- .../discourse/lib/rich-editor-extension.js | 48 +++++++++++-------- .../integration/rich-editor-extension-test.js | 10 ++++ test/javascripts/unit/lib/to-markdown-test.js | 31 ++++++++++++ 3 files changed, 69 insertions(+), 20 deletions(-) create mode 100644 test/javascripts/unit/lib/to-markdown-test.js diff --git a/assets/javascripts/discourse/lib/rich-editor-extension.js b/assets/javascripts/discourse/lib/rich-editor-extension.js index d453ec1..8b73a55 100644 --- a/assets/javascripts/discourse/lib/rich-editor-extension.js +++ b/assets/javascripts/discourse/lib/rich-editor-extension.js @@ -188,17 +188,30 @@ function wrapInTag(state, node, tag) { state.closeBlock(node); } +// the cook writes styling as a classless span holding exactly one declaration, +// and a copy keeps an authored style attribute as it is. styles a browser +// inlined at copy time arrive as multi-declaration batches on elements that +// keep their identity, and must not come back as authored bbcode. +function styledSpan(property, getAttrs) { + return { + tag: "span[style]:not([class])", + getAttrs: (dom) => { + const { style } = dom; + return style.length === 1 && style.item(0) === property + ? getAttrs(style.getPropertyValue(property)) + : false; + }, + }; +} + function colorMark(property) { return { attrs: { color: {} }, parseDOM: [ - { - style: property, - getAttrs: (value) => { - const color = normalizeColor(value); - return color ? { color } : false; - }, - }, + styledSpan(property, (value) => { + const color = normalizeColor(value); + return color ? { color } : false; + }), ], toDOM: (mark) => ["span", { style: `${property}:${mark.attrs.color}` }, 0], }; @@ -224,24 +237,19 @@ const extension = { bbcode_size: { attrs: { size: {} }, parseDOM: [ - { - style: "font-size", - getAttrs: (value) => - SIZE_VALUE.test(value) ? { size: parseInt(value, 10) } : false, - }, + styledSpan("font-size", (value) => + SIZE_VALUE.test(value) ? { size: parseInt(value, 10) } : false + ), ], toDOM: (mark) => ["span", { style: `font-size:${mark.attrs.size}%` }, 0], }, bbcode_font: { attrs: { font: {} }, parseDOM: [ - { - style: "font-family", - getAttrs: (value) => { - const font = unquoteFont(value); - return font ? { font } : false; - }, - }, + styledSpan("font-family", (value) => { + const font = unquoteFont(value); + return font ? { font } : false; + }), ], toDOM: (mark) => [ "span", @@ -256,7 +264,7 @@ const extension = { toDOM: () => ["span", { class: "highlight" }, 0], }, bbcode_small: { - parseDOM: [{ style: "font-size=x-small" }], + parseDOM: [styledSpan("font-size", (value) => value === "x-small" && {})], toDOM: () => ["span", { style: "font-size:x-small" }, 0], }, bbcode_aname: { diff --git a/test/javascripts/integration/rich-editor-extension-test.js b/test/javascripts/integration/rich-editor-extension-test.js index ab5c65e..f8eec8a 100644 --- a/test/javascripts/integration/rich-editor-extension-test.js +++ b/test/javascripts/integration/rich-editor-extension-test.js @@ -279,6 +279,16 @@ module( '

stacked

', "stacked", ], + "pasted multi-declaration span html is not claimed": [ + '

calm

', + "calm", + ], + // a copied class-styled element keeps its class next to the styles the + // browser inlined from it + "pasted classed span html is not claimed": [ + '

calm

', + "calm", + ], "pasted generic font html is not claimed": [ '

mono

', "mono", diff --git a/test/javascripts/unit/lib/to-markdown-test.js b/test/javascripts/unit/lib/to-markdown-test.js new file mode 100644 index 0000000..d41ac20 --- /dev/null +++ b/test/javascripts/unit/lib/to-markdown-test.js @@ -0,0 +1,31 @@ +import { module, test } from "qunit"; +import { + registerRichEditorExtension, + resetRichEditorExtensions, +} from "discourse/lib/composer/rich-editor-extensions"; +import toMarkdown from "discourse/lib/to-markdown"; +import richEditorExtension from "discourse/plugins/discourse-bbcode/discourse/lib/rich-editor-extension"; + +module("discourse-bbcode | Unit | to-markdown", function (hooks) { + hooks.beforeEach(async function () { + await resetRichEditorExtensions(); + registerRichEditorExtension(richEditorExtension); + }); + + // copying rendered content inlines theme colors onto the copied elements + test("ignores styles the browser inlined on copied content", async function (assert) { + assert.strictEqual( + await toMarkdown( + `@someone a command` + ), + "@someone `a command`" + ); + }); + + test("keeps a cooked bbcode span", async function (assert) { + assert.strictEqual( + await toMarkdown(`red text`), + "[color=red]red[/color] text" + ); + }); +}); From bc0ab12a90166c5b7e845c11262c8836a0e65411 Mon Sep 17 00:00:00 2001 From: Renato Atilio Date: Mon, 17 Aug 2026 16:49:37 -0300 Subject: [PATCH 2/2] DEV: Trim comments and set up the to-markdown test owner --- assets/javascripts/discourse/lib/rich-editor-extension.js | 6 ++---- test/javascripts/integration/rich-editor-extension-test.js | 2 -- test/javascripts/unit/lib/to-markdown-test.js | 3 +++ 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/assets/javascripts/discourse/lib/rich-editor-extension.js b/assets/javascripts/discourse/lib/rich-editor-extension.js index 8b73a55..8e2e936 100644 --- a/assets/javascripts/discourse/lib/rich-editor-extension.js +++ b/assets/javascripts/discourse/lib/rich-editor-extension.js @@ -188,10 +188,8 @@ function wrapInTag(state, node, tag) { state.closeBlock(node); } -// the cook writes styling as a classless span holding exactly one declaration, -// and a copy keeps an authored style attribute as it is. styles a browser -// inlined at copy time arrive as multi-declaration batches on elements that -// keep their identity, and must not come back as authored bbcode. +// the cook writes a classless span holding a single declaration; anything +// looser is styling the browser inlined at copy time, not authored bbcode function styledSpan(property, getAttrs) { return { tag: "span[style]:not([class])", diff --git a/test/javascripts/integration/rich-editor-extension-test.js b/test/javascripts/integration/rich-editor-extension-test.js index f8eec8a..e165a1e 100644 --- a/test/javascripts/integration/rich-editor-extension-test.js +++ b/test/javascripts/integration/rich-editor-extension-test.js @@ -283,8 +283,6 @@ module( '

calm

', "calm", ], - // a copied class-styled element keeps its class next to the styles the - // browser inlined from it "pasted classed span html is not claimed": [ '

calm

', "calm", diff --git a/test/javascripts/unit/lib/to-markdown-test.js b/test/javascripts/unit/lib/to-markdown-test.js index d41ac20..123b0c2 100644 --- a/test/javascripts/unit/lib/to-markdown-test.js +++ b/test/javascripts/unit/lib/to-markdown-test.js @@ -1,3 +1,4 @@ +import { setupTest } from "ember-qunit"; import { module, test } from "qunit"; import { registerRichEditorExtension, @@ -7,6 +8,8 @@ import toMarkdown from "discourse/lib/to-markdown"; import richEditorExtension from "discourse/plugins/discourse-bbcode/discourse/lib/rich-editor-extension"; module("discourse-bbcode | Unit | to-markdown", function (hooks) { + setupTest(hooks); + hooks.beforeEach(async function () { await resetRichEditorExtensions(); registerRichEditorExtension(richEditorExtension);