From 623f02aff6c2c18747ee1cec6ed664342dfcb22f Mon Sep 17 00:00:00 2001 From: Renato Atilio Date: Tue, 11 Mar 2025 20:15:32 -0300 Subject: [PATCH] FEATURE: add details (plugin) rich editor extension (#31718) Continues the work done on https://github.com/discourse/discourse/pull/30815. Adds `details` and `summary` nodes, parsers, and serializers, and a click handler to toggle the `open` attribute. --------- Co-authored-by: Martin Brennan --- .../tests/helpers/rich-editor-helper.gjs | 37 +++++--- ...code-block-test.gjs => code-block-test.js} | 2 +- .../javascripts/initializers/apply-details.js | 3 + .../javascripts/lib/rich-editor-extension.js | 86 +++++++++++++++++++ .../assets/stylesheets/details.scss | 3 +- .../prosemirror-editor/details-test.js | 76 ++++++++++++++++ 6 files changed, 194 insertions(+), 13 deletions(-) rename app/assets/javascripts/discourse/tests/integration/components/prosemirror-editor/{code-block-test.gjs => code-block-test.js} (99%) create mode 100644 plugins/discourse-details/assets/javascripts/lib/rich-editor-extension.js create mode 100644 plugins/discourse-details/test/javascripts/integration/prosemirror-editor/details-test.js 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 ce4b011f898..6495ee1e8dd 100644 --- a/app/assets/javascripts/discourse/tests/helpers/rich-editor-helper.gjs +++ b/app/assets/javascripts/discourse/tests/helpers/rich-editor-helper.gjs @@ -2,12 +2,7 @@ import { tracked } from "@glimmer/tracking"; import { click, render, settled, waitFor } from "@ember/test-helpers"; import DEditor from "discourse/components/d-editor"; -export async function testMarkdown( - assert, - markdown, - expectedHtml, - expectedMarkdown -) { +export async function setupRichEditor(assert, markdown, multiToggle = false) { const self = new (class { @tracked value = markdown; @tracked view; @@ -26,10 +21,14 @@ 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"); + if (multiToggle) { + // ensure toggling to rich editor and back works + await click(".composer-toggle-switch"); + await click(".composer-toggle-switch"); + await click(".composer-toggle-switch"); + } else { + await click(".composer-toggle-switch"); + } await waitFor(".ProseMirror"); await settled(); @@ -64,9 +63,25 @@ export async function testMarkdown( // or a trailing-paragraph with an optional
inside .replace(/

(
)?<\/p>$/, ""); + return [self, html]; +} + +export async function testMarkdown( + assert, + markdown, + expectedHtml, + expectedMarkdown, + multiToggle = false +) { + const [editorClass, html] = await setupRichEditor( + assert, + markdown, + multiToggle + ); + assert.strictEqual(html, expectedHtml, `HTML should match for "${markdown}"`); assert.strictEqual( - self.value, + editorClass.value, expectedMarkdown, `Markdown should match for "${markdown}"` ); 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.js similarity index 99% rename from app/assets/javascripts/discourse/tests/integration/components/prosemirror-editor/code-block-test.gjs rename to app/assets/javascripts/discourse/tests/integration/components/prosemirror-editor/code-block-test.js index 11743fab9b7..866953eac48 100644 --- 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.js @@ -51,7 +51,7 @@ module( ], }).forEach(([name, [markdown, html, expectedMarkdown]]) => { test(name, async function (assert) { - await testMarkdown(assert, markdown, html, expectedMarkdown); + await testMarkdown(assert, markdown, html, expectedMarkdown, true); }); }); } diff --git a/plugins/discourse-details/assets/javascripts/initializers/apply-details.js b/plugins/discourse-details/assets/javascripts/initializers/apply-details.js index bd7a2d505d4..fd2ef6da623 100644 --- a/plugins/discourse-details/assets/javascripts/initializers/apply-details.js +++ b/plugins/discourse-details/assets/javascripts/initializers/apply-details.js @@ -1,6 +1,7 @@ import $ from "jquery"; import { withPluginApi } from "discourse/lib/plugin-api"; import { i18n } from "discourse-i18n"; +import richEditorExtension from "../lib/rich-editor-extension"; function initializeDetails(api) { api.decorateCooked(($elem) => $("details", $elem), { @@ -19,6 +20,8 @@ function initializeDetails(api) { icon: "caret-right", label: "details.title", }); + + api.registerRichEditorExtension(richEditorExtension); } export default { diff --git a/plugins/discourse-details/assets/javascripts/lib/rich-editor-extension.js b/plugins/discourse-details/assets/javascripts/lib/rich-editor-extension.js new file mode 100644 index 00000000000..e65688a2a08 --- /dev/null +++ b/plugins/discourse-details/assets/javascripts/lib/rich-editor-extension.js @@ -0,0 +1,86 @@ +/** @type {RichEditorExtension} */ +const extension = { + nodeSpec: { + details: { + allowGapCursor: true, + attrs: { open: { default: true } }, + content: "summary block+", + group: "block", + draggable: true, + selectable: true, + defining: true, + isolating: true, + parseDOM: [{ tag: "details" }], + toDOM: (node) => ["details", { open: node.attrs.open || undefined }, 0], + }, + summary: { + content: "inline*", + parseDOM: [{ tag: "summary" }], + toDOM: () => ["summary", 0], + }, + }, + parse: { + bbcode_open(state, token) { + if (token.tag === "details") { + state.openNode(state.schema.nodes.details, { + open: token.attrGet("open") !== null, + }); + return true; + } + + if (token.tag === "summary") { + state.openNode(state.schema.nodes.summary); + return true; + } + }, + bbcode_close(state, token) { + if (token.tag === "details" || token.tag === "summary") { + state.closeNode(); + return true; + } + }, + }, + serializeNode: { + details(state, node) { + state.renderContent(node); + state.write("[/details]\n\n"); + }, + summary(state, node, parent) { + let hasSummary = false; + // If the [details] tag has no summary. + if (node.content.childCount === 0) { + state.write("[details"); + } else { + hasSummary = true; + state.write('[details="'); + node.content.forEach( + (child) => + child.text && + state.text(child.text.replace(/"/g, "“"), state.inAutolink) + ); + } + let finalState = `${parent.attrs.open ? " open" : ""}]\n`; + if (hasSummary) { + finalState = `"${finalState}`; + } + state.write(finalState); + }, + }, + plugins: { + props: { + handleClickOn(view, pos, node, nodePos) { + if (node.type.name === "summary") { + const details = view.state.doc.nodeAt(nodePos - 1); + view.dispatch( + view.state.tr.setNodeMarkup(nodePos - 1, null, { + open: !details.attrs.open, + }) + ); + return true; + } + }, + }, + }, +}; + +export default extension; diff --git a/plugins/discourse-details/assets/stylesheets/details.scss b/plugins/discourse-details/assets/stylesheets/details.scss index a83b8bdcfed..422a9985ccb 100644 --- a/plugins/discourse-details/assets/stylesheets/details.scss +++ b/plugins/discourse-details/assets/stylesheets/details.scss @@ -3,7 +3,8 @@ details { .topic-body .cooked &, .d-editor-preview, - &.details__boxed { + &.details__boxed, + .ProseMirror & { background-color: var(--primary-very-low); padding: 0.25rem 0.75rem; margin-bottom: 0.5rem; diff --git a/plugins/discourse-details/test/javascripts/integration/prosemirror-editor/details-test.js b/plugins/discourse-details/test/javascripts/integration/prosemirror-editor/details-test.js new file mode 100644 index 00000000000..599f3f0fd07 --- /dev/null +++ b/plugins/discourse-details/test/javascripts/integration/prosemirror-editor/details-test.js @@ -0,0 +1,76 @@ +import { click } from "@ember/test-helpers"; +import { module, test } from "qunit"; +import { setupRenderingTest } from "discourse/tests/helpers/component-test"; +import { + setupRichEditor, + testMarkdown, +} from "discourse/tests/helpers/rich-editor-helper"; + +module( + "Integration | Component | prosemirror-editor - details extension", + function (hooks) { + setupRenderingTest(hooks); + + const testCases = { + details: [ + [ + `[details="Summary"]This text will be hidden[/details]`, + `

Summary

This text will be hidden

`, + `[details="Summary"]\nThis text will be hidden\n\n[/details]\n\n`, + ], + ], + "details with open attribute": [ + [ + `[details="Summary" open]This text will be hidden[/details]`, + `
Summary

This text will be hidden

`, + `[details="Summary" open]\nThis text will be hidden\n\n[/details]\n\n`, + ], + ], + "details without summary": [ + [ + `[details]This text will be hidden[/details]`, + `

This text will be hidden

`, + `[details]\nThis text will be hidden\n\n[/details]\n\n`, + ], + ], + "details without summary but with open attribute": [ + [ + `[details open]This text will be hidden[/details]`, + `

This text will be hidden

`, + `[details open]\nThis text will be hidden\n\n[/details]\n\n`, + ], + ], + }; + + Object.entries(testCases).forEach(([name, tests]) => { + tests.forEach(([markdown, expectedHtml, expectedMarkdown]) => { + test(name, async function (assert) { + this.siteSettings.rich_editor = true; + + await testMarkdown(assert, markdown, expectedHtml, expectedMarkdown); + }); + }); + }); + + test("opens and closes details on click", async function (assert) { + this.siteSettings.rich_editor = true; + const detailsMarkdown = `[details="Summary"]This text will be hidden[/details]`; + await setupRichEditor(assert, detailsMarkdown); + + const detailsCss = ".d-editor-input details"; + const summaryCss = `${detailsCss} summary`; + assert.dom(`${detailsCss} p`).isNotVisible(); + + await click(`${summaryCss}`); + assert.dom(`${detailsCss}`).hasAttribute("open"); + assert.dom(`${detailsCss} p`).isVisible(); + + // click elsewhere first to avoid a double-click being detected + await click(`${detailsCss} p`); + await click(`${summaryCss}`); + + assert.dom(`${detailsCss} p`).isNotVisible(); + assert.dom(`${detailsCss}`).doesNotHaveAttribute("open"); + }); + } +);