mirror of
https://github.com/discourse/discourse.git
synced 2025-05-24 22:51:24 +08:00
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 <martin@discourse.org>
This commit is contained in:
@ -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(
|
||||
</template>
|
||||
);
|
||||
|
||||
// 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 <br class="ProseMirror-trailingBreak"> inside
|
||||
.replace(/<p>(<br class="ProseMirror-trailingBreak">)?<\/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}"`
|
||||
);
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
@ -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 {
|
||||
|
@ -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;
|
@ -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;
|
||||
|
@ -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]`,
|
||||
`<details><summary>Summary</summary><p>This text will be hidden</p></details>`,
|
||||
`[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]`,
|
||||
`<details open="true"><summary>Summary</summary><p>This text will be hidden</p></details>`,
|
||||
`[details="Summary" open]\nThis text will be hidden\n\n[/details]\n\n`,
|
||||
],
|
||||
],
|
||||
"details without summary": [
|
||||
[
|
||||
`[details]This text will be hidden[/details]`,
|
||||
`<details><summary></summary><p>This text will be hidden</p></details>`,
|
||||
`[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]`,
|
||||
`<details open="true"><summary></summary><p>This text will be hidden</p></details>`,
|
||||
`[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");
|
||||
});
|
||||
}
|
||||
);
|
Reference in New Issue
Block a user