DEV: prosemirror

This commit is contained in:
Renato Atilio 2024-11-04 14:13:43 -03:00
parent 3fe98a0387
commit 8b50393229
No known key found for this signature in database
GPG Key ID: CBF93DCB5CBCA1A5
35 changed files with 892 additions and 249 deletions

View File

@ -2,43 +2,33 @@ import concatClass from "discourse/helpers/concat-class";
import icon from "discourse-common/helpers/d-icon"; import icon from "discourse-common/helpers/d-icon";
const ComposerToggleSwitch = <template> const ComposerToggleSwitch = <template>
<div <div class="composer-toggle-switch">
class="{{concatClass <label class="composer-toggle-switch__label">
'composer-toggle-switch' {{! template-lint-disable no-redundant-role }}
(if @state '--rte' '--markdown') <button
}}" class="composer-toggle-switch__checkbox"
> type="button"
role="switch"
{{! template-lint-disable no-redundant-role }} aria-checked={{if @state "true" "false"}}
<button ...attributes
class="composer-toggle-switch__button" ></button>
type="button"
role="switch"
aria-pressed={{if @state "true" "false"}}
...attributes
>
{{! template-lint-enable no-redundant-role }} {{! template-lint-enable no-redundant-role }}
<span class="composer-toggle-switch__slider" focusable="false"> <span class="composer-toggle-switch__checkbox-slider">
<span <span
class={{concatClass class={{concatClass
"composer-toggle-switch__left-icon" "composer-toggle-switch__left-icon"
(unless @state "--active") (unless @state "--active")
}} }}
aria-hidden="true"
focusable="false"
>{{icon "fab-markdown"}}</span> >{{icon "fab-markdown"}}</span>
<span <span
class={{concatClass class={{concatClass
"composer-toggle-switch__right-icon" "composer-toggle-switch__right-icon"
(if @state "--active") (if @state "--active")
}} }}
aria-hidden="true"
focusable="false"
>{{icon "a"}}</span> >{{icon "a"}}</span>
</span> </span>
</button> </label>
</div> </div>
</template>; </template>;

View File

@ -9,13 +9,13 @@
> >
<div class="d-editor-button-bar" role="toolbar"> <div class="d-editor-button-bar" role="toolbar">
{{#if this.siteSettings.experimental_rich_editor}} {{#if this.siteSettings.experimental_rich_editor}}
<Composer::ToggleSwitch <Composer::ToggleSwitch
@state={{this.isRichEditorEnabled}} @state={{this.isRichEditorEnabled}}
{{on "click" this.toggleRichEditor}} {{on "click" this.toggleRichEditor}}
/> />
{{/if}} {{/if}}
{{#each this.toolbar.groups as |group|}} {{#each this.toolbar.groups as |group|}}
{{#each group.buttons as |b|}} {{#each group.buttons as |b|}}
{{#if (b.condition this)}} {{#if (b.condition this)}}
{{#if b.popupMenu}} {{#if b.popupMenu}}
@ -56,6 +56,8 @@
@change={{this.onChange}} @change={{this.onChange}}
@focusIn={{this.handleFocusIn}} @focusIn={{this.handleFocusIn}}
@focusOut={{this.handleFocusOut}} @focusOut={{this.handleFocusOut}}
@categoryId={{@categoryId}}
@topicId={{@topicId}}
@id={{this.textAreaId}} @id={{this.textAreaId}}
/> />
<PopupInputTip @validation={{this.validation}} /> <PopupInputTip @validation={{this.validation}} />

View File

@ -11,12 +11,14 @@ import {
getNodeViews, getNodeViews,
getPlugins, getPlugins,
} from "discourse/lib/composer/rich-editor-extensions"; } from "discourse/lib/composer/rich-editor-extensions";
import * as ProsemirrorModel from "prosemirror-model";
import * as ProsemirrorView from "prosemirror-view";
import { createHighlight } from "../plugins/code-highlight"; import { createHighlight } from "../plugins/code-highlight";
import { baseKeymap } from "prosemirror-commands"; import { baseKeymap } from "prosemirror-commands";
import { dropCursor } from "prosemirror-dropcursor"; import { dropCursor } from "prosemirror-dropcursor";
import { gapCursor } from "prosemirror-gapcursor";
import { history } from "prosemirror-history"; import { history } from "prosemirror-history";
import { keymap } from "prosemirror-keymap"; import { keymap } from "prosemirror-keymap";
import * as ProsemirrorState from "prosemirror-state";
import { EditorState, Plugin } from "prosemirror-state"; import { EditorState, Plugin } from "prosemirror-state";
import { EditorView } from "prosemirror-view"; import { EditorView } from "prosemirror-view";
import { bind } from "discourse-common/utils/decorators"; import { bind } from "discourse-common/utils/decorators";
@ -54,18 +56,20 @@ export default class ProsemirrorEditor extends Component {
this.plugins ??= [ this.plugins ??= [
buildInputRules(this.schema), buildInputRules(this.schema),
// TODO buildPasteRules(),
keymap(buildKeymap(this.schema, keymapFromArgs)), keymap(buildKeymap(this.schema, keymapFromArgs)),
keymap(baseKeymap), keymap(baseKeymap),
dropCursor({ color: "var(--primary)" }), dropCursor({ color: "var(--primary)" }),
gapCursor(),
history(), history(),
placeholder(this.args.placeholder), placeholder(this.args.placeholder),
createHighlight(), createHighlight(),
...getPlugins().map((plugin) => ...getPlugins().map((plugin) =>
// can be either a function that receives the Plugin class, typeof plugin === "function"
// or a plugin spec to be passed directly to the Plugin constructor ? plugin({
typeof plugin === "function" ? plugin(Plugin) : new Plugin(plugin) ...ProsemirrorState,
...ProsemirrorModel,
...ProsemirrorView,
})
: new Plugin(plugin)
), ),
]; ];
@ -75,6 +79,10 @@ export default class ProsemirrorEditor extends Component {
}); });
this.view = new EditorView(this.rootElement, { this.view = new EditorView(this.rootElement, {
discourse: {
topicId: this.args.topicId,
categoryId: this.args.categoryId,
},
nodeViews: this.args.nodeViews ?? getNodeViews(), nodeViews: this.args.nodeViews ?? getNodeViews(),
state: this.state, state: this.state,
attributes: { class: "d-editor-input d-editor__editable" }, attributes: { class: "d-editor-input d-editor__editable" },
@ -98,8 +106,7 @@ export default class ProsemirrorEditor extends Component {
}, },
}, },
handleKeyDown: (view, event) => { handleKeyDown: (view, event) => {
// this happens before the autocomplete event, so we check if it's open // skip the event if it's an Enter keypress and the autocomplete is open
// TODO(renato): find a better way to handle these events, or just a better check
return ( return (
event.key === "Enter" && !!document.querySelector(".autocomplete") event.key === "Enter" && !!document.querySelector(".autocomplete")
); );
@ -114,14 +121,13 @@ export default class ProsemirrorEditor extends Component {
this.destructor = this.args.onSetup(this.textManipulation); this.destructor = this.args.onSetup(this.textManipulation);
await this.convertFromValue(); this.convertFromValue();
} }
@bind @bind
async convertFromValue() { convertFromValue() {
const doc = await convertFromMarkdown(this.schema, this.args.value); const doc = convertFromMarkdown(this.schema, this.args.value);
// doc.check();
// console.log("Resulting doc:", doc); // console.log("Resulting doc:", doc);
const tr = this.state.tr const tr = this.state.tr

View File

@ -0,0 +1,92 @@
import { common, createLowlight } from "lowlight";
class CodeBlockWithLangSelectorNodeView {
changeListener = (e) =>
this.view.dispatch(
this.view.state.tr.setNodeMarkup(this.getPos(), null, {
params: e.target.value,
})
);
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("d-editor__code-block");
pre.appendChild(this.buildSelect());
this.dom = pre;
this.contentDOM = code;
}
buildSelect() {
const select = document.createElement("select");
select.contentEditable = false;
select.addEventListener("change", this.changeListener);
select.classList.add("d-editor__code-lang-select");
const empty = document.createElement("option");
empty.textContent = "";
select.appendChild(empty);
createLowlight(common)
.listLanguages()
.forEach((lang) => {
const option = document.createElement("option");
option.textContent = lang;
option.selected = lang === this.node.attrs.params;
select.appendChild(option);
});
return select;
}
update(node) {
return node.type === this.node.type;
}
destroy() {
this.dom.removeEventListener("change", this.changeListener);
}
}
export default {
nodeViews: { code_block: CodeBlockWithLangSelectorNodeView },
plugins: {
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;
}
},
},
},
};

View File

@ -30,7 +30,7 @@ export default {
inputRules: [ inputRules: [
{ {
match: /#([\u00C0-\u1FFF\u2C00-\uD7FF\w:-]{1,101}) $/, match: /(?<=^|\W)#([\u00C0-\u1FFF\u2C00-\uD7FF\w:-]{1,101}) $/,
handler: (state, match, start, end) => handler: (state, match, start, end) =>
state.selection.$from.nodeBefore?.type !== state.schema.nodes.hashtag && state.selection.$from.nodeBefore?.type !== state.schema.nodes.hashtag &&
state.tr.replaceWith(start, end, [ state.tr.replaceWith(start, end, [

View File

@ -0,0 +1,6 @@
export default {
nodeSpec: {
// TODO(renato): html_block should be like a passthrough code block
html_block: { block: "paragraph", noCloseToken: true },
},
};

View File

@ -19,6 +19,8 @@ const ALLOWED_INLINE = [
"mark", "mark",
]; ];
const ALL_ALLOWED_TAGS = [...Object.keys(HTML_INLINE_MARKS), ...ALLOWED_INLINE];
export default { export default {
nodeSpec: { nodeSpec: {
// TODO(renato): this node is hard to get past when at the end of a block // TODO(renato): this node is hard to get past when at the end of a block
@ -47,7 +49,7 @@ export default {
} }
if (ALLOWED_INLINE.includes(tagName)) { if (ALLOWED_INLINE.includes(tagName)) {
state.openNode(state.schema.nodeType("html_inline"), { state.openNode(state.schema.nodeType.html_inline, {
tag: tagName, tag: tagName,
}); });
} }
@ -77,16 +79,37 @@ export default {
}, },
}, },
inputRules: { inputRules: {
match: new RegExp(`<(${ALLOWED_INLINE.join("|")})>`), match: new RegExp(`<(${ALL_ALLOWED_TAGS.join("|")})>$`, "i"),
handler: (state, match, start, end) => { handler: (state, match, start, end) => {
const tag = match[1]; const tag = match[1];
// TODO not finished const markName = HTML_INLINE_MARKS[tag];
state.tr.replaceWith(
start, const tr = state.tr;
end,
state.schema.nodes.html_inline.create({ tag }) if (markName) {
tr.delete(start, end);
tr.insertText(" ");
tr.addMark(start, start + 1, state.schema.marks[markName].create());
tr.removeStoredMark(state.schema.marks[markName]);
} else {
tr.replaceWith(
start,
end,
state.schema.nodes.html_inline.create({ tag }, [
state.schema.text(" "),
])
);
start += 1;
}
tr.insertText(" ");
tr.setSelection(
state.selection.constructor.create(tr.doc, start, start + 1)
); );
return tr;
}, },
}, },
}; };

View File

@ -106,7 +106,7 @@ export default {
}, },
}, },
plugins: (Plugin) => { plugins: ({ Plugin }) => {
const shortUrlResolver = new Plugin({ const shortUrlResolver = new Plugin({
state: { state: {
init() { init() {

View File

@ -1,35 +1,43 @@
import { registerRichEditorExtension } from "discourse/lib/composer/rich-editor-extensions"; import { registerRichEditorExtension } from "discourse/lib/composer/rich-editor-extensions";
import codeLangSelector from "./code-lang-selector"; import codeBlock from "./code-block";
import emojiExtension from "./emoji"; import emoji from "./emoji";
import hashtagExtension from "./hashtag"; import hashtag from "./hashtag";
import headingExtension from "./heading"; import heading from "./heading";
import htmlInlineExtension from "./html-inline"; import htmlBlock from "./html-block";
import imageExtension from "./image"; import htmlInline from "./html-inline";
import linkExtension from "./link"; import image from "./image";
import mentionExtension from "./mention"; import link from "./link";
import quoteExtension from "./quote"; import markdownPaste from "./markdown-paste";
import strikethroughExtension from "./strikethrough"; import mention from "./mention";
import tableExtension from "./table"; import onebox from "./onebox";
import quote from "./quote";
import strikethrough from "./strikethrough";
import table from "./table";
import trailingParagraph from "./trailing-paragraph";
import typographerReplacements from "./typographer-replacements"; import typographerReplacements from "./typographer-replacements";
import underlineExtension from "./underline"; import underline from "./underline";
const defaultExtensions = [ const defaultExtensions = [
emojiExtension, emoji,
// image must be after emoji // image must be after emoji
imageExtension, image,
hashtagExtension, hashtag,
mentionExtension, mention,
strikethroughExtension, strikethrough,
underlineExtension, underline,
htmlInlineExtension, htmlInline,
linkExtension, htmlBlock,
headingExtension, link,
heading,
codeBlock,
quote,
onebox,
trailingParagraph,
typographerReplacements, typographerReplacements,
codeLangSelector, markdownPaste,
quoteExtension,
// table must be last // table must be last
tableExtension, table,
]; ];
defaultExtensions.forEach(registerRichEditorExtension); defaultExtensions.forEach(registerRichEditorExtension);

View File

@ -1,32 +1,39 @@
const HTTP_MAILTO_REGEX = new RegExp(
/(?:(?:(https|http|ftp)+):\/\/)?(?:\S+(?::\S*)?(@))?(?:(?:([a-z0-9][a-z0-9\-]*)?[a-z0-9]+)(?:\.(?:[a-z0-9\-])*[a-z0-9]+)*(?:\.(?:[a-z]{2,})(:\d{1,5})?))(?:\/[^\s]*)?\s $/
);
// TODO use site settings
export default { export default {
inputRules: [ inputRules: [
{ // []() replacement
match: HTTP_MAILTO_REGEX, ({ schema, markInputRule }) =>
handler: (state, match, start, end) => { markInputRule(
const markType = state.schema.marks.link; /\[([^\]]+)]\(([^)\s]+)(?:\s+[“"']([^“"']+)[”"'])?\)$/,
schema.marks.link,
const resolvedStart = state.doc.resolve(start); (match) => {
if (!resolvedStart.parent.type.allowsMarkType(markType)) { return { href: match[2], title: match[3] };
return null;
} }
),
const link = match[0].substring(0, match[0].length - 1); // TODO(renato): auto-linkify when typing (https://github.com/markdown-it/markdown-it/blob/master/lib/rules_inline/autolink.mjs)
const linkAttrs =
match[2] === "@"
? { href: "mailto:" + link }
: { href: link, target: "_blank" };
const linkTo = markType.create(linkAttrs);
return state.tr
.removeMark(start, end, markType)
.addMark(start, end, linkTo)
.insertText(match[5], start);
},
},
], ],
plugins: ({ Plugin, Slice, Fragment }) =>
new Plugin({
props: {
// Auto-linkify plain-text pasted URLs
// TODO(renato): URLs copied from HTML will go through the regular HTML parsing
// it would be nice to auto-linkify them too
clipboardTextParser(text, $context, plain, view) {
// TODO(renato): a less naive regex, reuse existing
if (!text.match(/^https?:\/\//) || view.state.selection.empty) {
return;
}
const marks = $context.marks();
const selectedText = view.state.doc.textBetween(
view.state.selection.from,
view.state.selection.to
);
const textNode = view.state.schema.text(selectedText, [
...marks,
view.state.schema.marks.link.create({ href: text }),
]);
return new Slice(Fragment.from(textNode), 0, 0);
},
},
}),
}; };

View File

@ -0,0 +1,15 @@
import { convertFromMarkdown } from "../lib/parser";
export default {
plugins({ Plugin, Fragment, Slice }) {
return new Plugin({
props: {
clipboardTextParser(text, $context, plain, view) {
const { content } = convertFromMarkdown(view.state.schema, text);
return Slice.maxOpen(Fragment.from(content));
},
},
});
},
};

View File

@ -34,8 +34,8 @@ export default {
inputRules: [ inputRules: [
{ {
// TODO: pass unicodeUsernames? // TODO(renato): pass unicodeUsernames?
match: new RegExp(`(${mentionRegex().source}) $`), match: new RegExp(`(?<=^|\\W)(${mentionRegex().source}) $`),
handler: (state, match, start, end) => handler: (state, match, start, end) =>
state.selection.$from.nodeBefore?.type !== state.schema.nodes.mention && state.selection.$from.nodeBefore?.type !== state.schema.nodes.mention &&
state.tr.replaceWith(start, end, [ state.tr.replaceWith(start, end, [

View File

@ -0,0 +1,97 @@
import { cachedInlineOnebox } from "pretty-text/inline-oneboxer";
import { lookupCache } from "pretty-text/oneboxer-cache";
export default {
nodeSpec: {
onebox: {
attrs: { url: {}, html: {} },
selectable: false,
group: "inline",
inline: true,
atom: true,
draggable: true,
parseDOM: [
{
tag: "aside.onebox",
getAttrs(dom) {
return { url: dom["data-onebox-src"], html: dom.outerHTML };
},
},
],
toDOM(node) {
// const dom = document.createElement("aside");
// dom.outerHTML = node.attrs.html;
// TODO(renato): revisit?
return new DOMParser().parseFromString(node.attrs.html, "text/html")
.body.firstChild;
},
},
},
serializeNode: {
onebox(state, node) {
state.write(node.attrs.url);
},
},
plugins: ({ Plugin }) => {
const plugin = new Plugin({
state: {
init() {
return [];
},
apply(tr, value) {
// TODO(renato)
return value;
},
},
view() {
return {
update(view, prevState) {
if (prevState.doc.eq(view.state.doc)) {
return;
}
// console.log("discourse", view.props.discourse);
const unresolvedLinks = plugin.getState(view.state);
// console.log(unresolvedLinks);
for (const unresolved of unresolvedLinks) {
const isInline = unresolved.isInline;
// console.log(isInline, cachedInlineOnebox(unresolved.text));
const className = isInline
? "onebox-loading"
: "inline-onebox-loading";
if (!isInline) {
// console.log(lookupCache(unresolved.text));
}
}
},
};
},
});
return plugin;
},
};
function isValidUrl(text) {
try {
new URL(text); // If it can be parsed as a URL, it's valid.
return true;
} catch {
return false;
}
}
function isNodeInline(state, pos) {
const resolvedPos = state.doc.resolve(pos);
const parent = resolvedPos.parent;
return parent.childCount !== 1;
}

View File

@ -83,7 +83,7 @@ export default {
state.renderContent(n); state.renderContent(n);
} }
}); });
state.write("[/quote]\n"); state.write("[/quote]\n\n");
}, },
quote_title() {}, quote_title() {},
}, },

View File

@ -15,6 +15,8 @@ export default {
}, },
}, },
}, },
inputRules: ({ schema, markInputRule }) =>
markInputRule(/~~([^~]+)~~$/, schema.marks.strikethrough),
parse: { parse: {
s: { mark: "strikethrough" }, s: { mark: "strikethrough" },
bbcode_s: { mark: "strikethrough" }, bbcode_s: { mark: "strikethrough" },

View File

@ -13,7 +13,7 @@
export default { export default {
nodeSpec: { nodeSpec: {
table: { table: {
content: "(table_head | table_body)+", content: "table_head table_body",
group: "block", group: "block",
tableRole: "table", tableRole: "table",
isolating: true, isolating: true,
@ -61,7 +61,15 @@ export default {
}, },
], ],
toDOM(node) { toDOM(node) {
return ["th", { style: `text-align: ${node.attrs.alignment}` }, 0]; return [
"th",
{
style: node.attrs.alignment
? `text-align: ${node.attrs.alignment}`
: undefined,
},
0,
];
}, },
}, },
table_cell: { table_cell: {

View File

@ -0,0 +1,31 @@
export default {
plugins({ Plugin, PluginKey }) {
const plugin = new PluginKey("trailing-paragraph");
return new Plugin({
key: plugin,
appendTransaction(_, __, state) {
if (!plugin.getState(state)) {
return;
}
return state.tr.insert(
state.doc.content.size,
state.schema.nodes.paragraph.create()
);
},
state: {
init(_, state) {
return state.doc.lastChild.type !== state.schema.nodes.paragraph;
},
apply(tr, value) {
if (!tr.docChanged) {
return value;
}
return tr.doc.lastChild.type !== tr.doc.type.schema.nodes.paragraph;
},
},
});
},
};

View File

@ -7,6 +7,8 @@ export default {
parseDOM: [{ tag: "u" }], parseDOM: [{ tag: "u" }],
}, },
}, },
inputRules: ({ schema, markInputRule }) =>
markInputRule(/\[u]$/, schema.marks.underline),
parse: { parse: {
bbcode_u: { mark: "underline" }, bbcode_u: { mark: "underline" },
}, },

View File

@ -1,49 +1,56 @@
import { defaultMarkdownParser, MarkdownParser } from "prosemirror-markdown"; import { defaultMarkdownParser, MarkdownParser } from "prosemirror-markdown";
import { getParsers } from "discourse/lib/composer/rich-editor-extensions"; import { getParsers } from "discourse/lib/composer/rich-editor-extensions";
import { parseAsync } from "discourse/lib/text"; import { parse as markdownItParse } from "discourse/static/markdown-it";
import loadPluginFeatures from "discourse/static/markdown-it/features";
import defaultFeatures from "discourse-markdown-it/features/index";
// TODO(renato): We need a workaround for this parsing issue: // TODO(renato): We need a workaround for this parsing issue:
// https://github.com/ProseMirror/prosemirror-markdown/issues/82 // https://github.com/ProseMirror/prosemirror-markdown/issues/82
// a solution may be a markStack in the state ignoring nested marks // a solution may be a markStack in the state ignoring nested marks
const [parseFunctions, parseDefinitions] = Object.entries(getParsers()).reduce(
([funcs, nonFuncs], [key, value]) => {
if (typeof value === "function") {
funcs[key] = value;
} else {
nonFuncs[key] = value;
}
return [funcs, nonFuncs];
},
[{}, {}]
);
const parseTokens = { const parseTokens = {
...defaultMarkdownParser.tokens, ...defaultMarkdownParser.tokens,
// Custom
bbcode_b: { mark: "strong" }, bbcode_b: { mark: "strong" },
bbcode_i: { mark: "em" }, bbcode_i: { mark: "em" },
// TODO(renato): html_block should be like a passthrough code block
html_block: { block: "paragraph", noCloseToken: true },
...parseDefinitions,
}; };
// Overriding Prosemirror default parse definitions // Overriding Prosemirror default parse definitions with custom handlers
const postParseTokens = { const postParseTokens = {
softbreak: (state) => state.addText("\n"), softbreak: (state) => state.addNode(state.schema.nodes.hard_break),
...parseFunctions,
}; };
export async function convertFromMarkdown(schema, text) { let parseOptions;
const tokens = await parseAsync(text); function initializeParser() {
if (parseOptions) {
return;
}
for (const [key, value] of Object.entries(getParsers())) {
if (typeof value === "function") {
postParseTokens[key] = value;
} else {
parseTokens[key] = value;
}
}
const featuresOverride = [...defaultFeatures, ...loadPluginFeatures()]
.map(({ id }) => id)
// Avoid oneboxing when parsing, we'll handle that separately
.filter((id) => id !== "onebox");
parseOptions = { featuresOverride };
}
export function convertFromMarkdown(schema, text) {
initializeParser();
const tokens = markdownItParse(text, parseOptions);
console.log("Converting tokens", tokens); console.log("Converting tokens", tokens);
const dummyTokenizer = { parse: () => tokens }; const dummyTokenizer = { parse: () => tokens };
const parser = new MarkdownParser(schema, dummyTokenizer, parseTokens); const parser = new MarkdownParser(schema, dummyTokenizer, parseTokens);
// workaround for custom (fn) handlers
for (const [key, callback] of Object.entries(postParseTokens)) { for (const [key, callback] of Object.entries(postParseTokens)) {
parser.tokenHandlers[key] = callback; parser.tokenHandlers[key] = callback;
} }

View File

@ -93,26 +93,30 @@ export default class TextManipulation {
return; return;
} }
// TODO other cases, probably through md parser const text = i18n(`composer.${exampleKey}`);
const doc = convertFromMarkdown(this.schema, head + text + tail);
this.view.dispatch(
this.view.state.tr.replaceWith(sel.start, sel.end, doc.content.firstChild)
);
} }
async addText(sel, text, options) { addText(sel, text, options) {
const doc = await convertFromMarkdown( const doc = convertFromMarkdown(this.schema, text, this.markdownOptions);
this.schema,
text,
this.markdownOptions
);
// assumes it returns a single block node // assumes it returns a single block node
const content = doc.content.firstChild.content; const content =
doc.content.firstChild.type.name === "paragraph"
? doc.content.firstChild.content
: doc.content.firstChild;
this.view.dispatch( this.view.dispatch(
this.view.state.tr.replaceWith(sel.start, sel.end, content) this.view.state.tr.replaceWith(sel.start, sel.end, content)
); );
} }
async insertBlock(block) { insertBlock(block) {
const doc = await convertFromMarkdown(this.schema, block); const doc = convertFromMarkdown(this.schema, block);
this.view.dispatch( this.view.dispatch(
this.view.state.tr.replaceWith( this.view.state.tr.replaceWith(
@ -161,6 +165,8 @@ export default class TextManipulation {
command = isInside(applyListMap[exampleKey]) command = isInside(applyListMap[exampleKey])
? lift ? lift
: wrapIn(applyListMap[exampleKey]); : wrapIn(applyListMap[exampleKey]);
} else {
// TODO(renato): fallback to markdown parsing
} }
} }
@ -220,31 +226,30 @@ export default class TextManipulation {
} }
@bind @bind
paste(e) { paste() {
// TODO // Intentionally no-op
console.log("paste"); // Pasting markdown is being handled by the markdown-paste extension
// let { clipboard, canPasteHtml, canUpload } = clipboardHelpers(e, { // Pasting an url on top of a text is being handled by the link extension
// siteSettings: this.siteSettings,
// canUpload: true,
// });
// console.log(clipboard);
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
} }
selectText() { selectText(from, length, opts) {
// TODO const tr = this.view.state.tr.setSelection(
new this.view.state.selection.constructor(
this.view.state.doc.resolve(from),
this.view.state.doc.resolve(from + length)
)
);
if (opts.scroll) {
tr.scrollIntoView();
}
this.view.dispatch(tr);
} }
@bind @bind
inCodeBlock() { inCodeBlock() {
return ( return this.autocompleteHandler.inCodeBlock();
this.view.state.selection.$from.parent.type ===
this.schema.nodes.code_block
);
} }
/** /**
@ -287,7 +292,7 @@ class AutocompleteHandler {
* @param {number} end * @param {number} end
* @param {String} term * @param {String} term
*/ */
async replaceTerm({ start, end, term }) { replaceTerm({ start, end, term }) {
const node = this.view.state.selection.$head.nodeBefore; const node = this.view.state.selection.$head.nodeBefore;
const from = this.view.state.selection.from - node.nodeSize + start; const from = this.view.state.selection.from - node.nodeSize + start;
const to = this.view.state.selection.from - node.nodeSize + end + 1; const to = this.view.state.selection.from - node.nodeSize + end + 1;
@ -307,7 +312,7 @@ class AutocompleteHandler {
// ); // );
// } // }
const doc = await convertFromMarkdown(this.schema, term); const doc = convertFromMarkdown(this.schema, term);
const tr = this.view.state.tr.replaceWith( const tr = this.view.state.tr.replaceWith(
from, from,
@ -355,8 +360,10 @@ class AutocompleteHandler {
} }
inCodeBlock() { inCodeBlock() {
// TODO return (
return false; this.view.state.selection.$from.parent.type ===
this.schema.nodes.code_block
);
} }
} }
@ -418,7 +425,7 @@ class PlaceholderHandler {
}); });
} }
async success(file, markdown) { success(file, markdown) {
let nodeToReplace = null; let nodeToReplace = null;
this.view.state.doc.descendants((node, pos) => { this.view.state.doc.descendants((node, pos) => {
if ( if (
@ -433,7 +440,7 @@ class PlaceholderHandler {
}); });
// keeping compatibility with plugins that change the image node via markdown // keeping compatibility with plugins that change the image node via markdown
const doc = await convertFromMarkdown(this.schema, markdown); const doc = convertFromMarkdown(this.schema, markdown);
this.view.dispatch( this.view.dispatch(
this.view.state.tr.replaceWith( this.view.state.tr.replaceWith(

View File

@ -44,10 +44,21 @@ function markInputRule(regexp, markType, getAttrs) {
tr.delete(start, textStart); tr.delete(start, textStart);
} }
end = start + match[1].length; end = start + match[1].length;
tr.addMark(start, end, markType.create(attrs));
tr.removeStoredMark(markType);
} else {
tr.delete(start, end);
tr.insertText(" ");
tr.addMark(start, start + 1, markType.create(attrs));
tr.removeStoredMark(markType);
tr.insertText(" ");
tr.setSelection(
state.selection.constructor.create(tr.doc, start, start + 1)
);
} }
tr.addMark(start, end, markType.create(attrs));
tr.removeStoredMark(markType);
return tr; return tr;
}); });
} }
@ -82,23 +93,9 @@ export function buildInputRules(schema) {
const markInputRules = [ const markInputRules = [
markInputRule(/\*\*([^*]+)\*\*$/, marks.strong), markInputRule(/\*\*([^*]+)\*\*$/, marks.strong),
markInputRule(/(?<=^|\s)__([^_]+)__$/, marks.strong), markInputRule(/(?<=^|\s)__([^_]+)__$/, marks.strong),
markInputRule(/(?:^|(?<!\*))\*([^*]+)\*$/, marks.em), markInputRule(/(?:^|(?<!\*))\*([^*]+)\*$/, marks.em),
markInputRule(/(?<=^|\s)_([^_]+)_$/, marks.em), markInputRule(/(?<=^|\s)_([^_]+)_$/, marks.em),
markInputRule(
/\[([^\]]+)]\(([^)\s]+)(?:\s+[“"']([^“"']+)[”"'])?\)$/,
marks.link,
(match) => {
return { href: match[2], title: match[3] };
}
),
markInputRule(/`([^`]+)`$/, marks.code), markInputRule(/`([^`]+)`$/, marks.code),
markInputRule(/~~([^~]+)~~$/, marks.strikethrough),
markInputRule(/\[u]([^[]+)\[\/u]$/, marks.underline),
]; ];
rules = rules rules = rules
@ -114,7 +111,7 @@ export function buildInputRules(schema) {
function processInputRule(inputRule, schema) { function processInputRule(inputRule, schema) {
if (inputRule instanceof Array) { if (inputRule instanceof Array) {
return inputRule.map(processInputRule); return inputRule.map((rule) => processInputRule(rule, schema));
} }
if (inputRule instanceof Function) { if (inputRule instanceof Function) {

View File

@ -57,6 +57,10 @@ export function buildKeymap(schema, initialKeymap = {}, suppressKeys) {
bind("Mod-`", toggleMark(type)); bind("Mod-`", toggleMark(type));
} }
if ((type = schema.marks.underline)) {
bind("Mod-u", toggleMark(type));
}
if ((type = schema.nodes.blockquote)) { if ((type = schema.nodes.blockquote)) {
bind("Ctrl->", wrapIn(type)); bind("Ctrl->", wrapIn(type));
} }

View File

@ -1,96 +1,146 @@
.composer-toggle-switch { .composer-toggle-switch {
--toggle-switch-width: 40px; $root: &;
--toggle-switch-width: 48px;
--toggle-switch-height: 24px; --toggle-switch-height: 24px;
height: 100%;
grid-column: span 2; grid-column: span 2;
justify-content: center; justify-content: center;
&:focus {
&__checkbox-slider {
outline: 2px solid var(--tertiary);
outline-offset: 2px;
}
}
&:hover {
&__checkbox-slider {
background-color: var(--primary-high);
}
&__checkbox[aria-checked="true"]:not([disabled])
+ #{$root}__checkbox-slider {
background-color: var(--tertiary-hover);
}
}
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem;
&__button { &__label {
position: relative;
display: inline-block;
cursor: pointer;
margin: 0;
}
&__checkbox {
position: absolute;
border: 0; border: 0;
padding: 0; padding: 0;
background: transparent; background: transparent;
&:focus-visible { &:focus {
outline: none; + #{$root}__checkbox-slider {
outline: 2px solid var(--tertiary);
outline-offset: 2px;
}
// Outline should show only when tabbing, not clicking
&:not(:focus-visible) {
+ #{$root}__checkbox-slider {
outline: none;
}
}
} }
&[disabled] { }
opacity: 0.5;
&__checkbox[aria-checked="true"] + &__checkbox-slider::before {
left: calc(var(--toggle-switch-width) - var(--toggle-switch-height));
}
&__checkbox[disabled] + &__checkbox-slider {
opacity: 0.5;
cursor: not-allowed;
&::before {
cursor: not-allowed; cursor: not-allowed;
} }
} }
&__slider { &__checkbox-slider {
display: inline-block; display: inline-block;
background: var(--primary-low); cursor: pointer;
background: var(--primary-low-mid);
width: var(--toggle-switch-width); width: var(--toggle-switch-width);
height: var(--toggle-switch-height); height: var(--toggle-switch-height);
position: relative; position: relative;
vertical-align: middle; vertical-align: middle;
border-radius: 0.25em; transition: background 0.25s;
:focus-visible & {
outline: 2px solid var(--tertiary);
outline-offset: 2px;
}
&:before {
content: "";
display: block;
position: absolute;
background-color: var(--tertiary-low);
width: calc(var(--toggle-switch-height) - 2px);
height: calc(var(--toggle-switch-height) - 4px);
top: 2px;
transition: left 0.25s, right 0.25s;
border-radius: 0.25em;
box-shadow: 0 1px 3px 1px rgba(0, 0, 0, 0.1);
.--markdown & {
left: 2px;
}
.--rte & {
right: 2px;
}
@media (prefers-reduced-motion: reduce) {
transition-duration: 0ms;
}
}
} }
&__left-icon, &__left-icon {
&__right-icon {
display: inline-block;
position: absolute;
opacity: 0; opacity: 0;
transition: opacity 0.25s left 0.25s, right 0.25s; transition: opacity 0.25s;
height: 100%;
width: calc(var(--toggle-switch-height) - 2px);
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
transition-duration: 0ms; transition-duration: 0ms;
} }
.--markdown & {
left: 2px;
}
.--rte & {
right: 2px;
}
&.--active { &.--active {
opacity: 1; opacity: 1;
} }
.d-icon { & .d-icon {
font-size: var(--font-down-1); font-size: var(--font-down-1);
color: var(--primary); color: var(--primary);
vertical-align: text-bottom; left: 5px;
top: 6px;
position: absolute;
}
}
&__right-icon {
opacity: 0;
transition: opacity 0.25s;
@media (prefers-reduced-motion: reduce) {
transition-duration: 0ms;
}
& .d-icon {
font-size: var(--font-down-1);
color: var(--primary);
right: 5px;
top: 6px;
position: absolute;
}
&.--active {
opacity: 1;
}
}
&__checkbox-slider::before,
&__checkbox-slider::after {
content: "";
display: block;
position: absolute;
cursor: pointer;
}
&__checkbox-slider::before {
background-color: var(--tertiary-low);
width: var(--toggle-switch-height);
height: var(--toggle-switch-height);
top: 0;
left: 0;
transition: left 0.25s;
@media (prefers-reduced-motion: reduce) {
transition-duration: 0ms;
} }
} }
} }

View File

@ -13,6 +13,10 @@
outline: 0; outline: 0;
padding: 0 0.625rem; padding: 0 0.625rem;
a {
pointer-events: none;
}
h1, h1,
h2, h2,
h3, h3,
@ -117,12 +121,16 @@
} }
} }
.d-editor__code-block {
position: relative;
}
.d-editor__code-lang-select { .d-editor__code-lang-select {
position: absolute; position: absolute;
right: 0.25rem; right: 0.25rem;
top: -0.6rem; top: -0.6rem;
border: 1px solid var(--primary-low); border: 1px solid var(--primary-low);
border-radius: var(--border-radius); border-radius: var(--d-border-radius);
background-color: var(--primary-very-low); background-color: var(--primary-very-low);
color: var(--primary-medium); color: var(--primary-medium);
font-size: var(--font-down-1-rem); font-size: var(--font-down-1-rem);

View File

@ -7,7 +7,7 @@ in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.40.0] - 2024-12-16 ## [1.40.0] - 2025-01-02
- Added `registerRichEditorExtension` which allows plugins/TCs to register an extension for the rich text editor. - Added `registerRichEditorExtension` which allows plugins/TCs to register an extension for the rich text editor.

View File

@ -1,15 +1,15 @@
export default { export default {
nodeSpec: { nodeSpec: {
details: { details: {
attrs: { open: { default: true } },
content: "summary block+", content: "summary block+",
group: "block", group: "block",
defining: true, defining: true,
parseDOM: [{ tag: "details" }], parseDOM: [{ tag: "details" }],
toDOM: () => ["details", { open: true }, 0], toDOM: (node) => ["details", { open: node.attrs.open || undefined }, 0],
}, },
summary: { summary: {
content: "inline*", content: "inline*",
group: "block",
parseDOM: [{ tag: "summary" }], parseDOM: [{ tag: "summary" }],
toDOM: () => ["summary", 0], toDOM: () => ["summary", 0],
}, },
@ -17,7 +17,9 @@ export default {
parse: { parse: {
bbcode(state, token) { bbcode(state, token) {
if (token.tag === "details") { if (token.tag === "details") {
state.openNode(state.schema.nodes.details); state.openNode(state.schema.nodes.details, {
open: token.attrGet("open") !== null,
});
return true; return true;
} }
@ -30,12 +32,31 @@ export default {
serializeNode: { serializeNode: {
details(state, node) { details(state, node) {
state.renderContent(node); state.renderContent(node);
state.write("[/details]\n"); state.write("[/details]\n\n");
}, },
summary(state, node) { summary(state, node, parent) {
state.write("[details="); state.write('[details="');
state.renderContent(node); node.content.forEach(
state.write("]\n"); (child) =>
child.text &&
state.text(child.text.replace(/"/g, "“"), state.inAutolink)
);
state.write(`"${parent.attrs.open ? " open" : ""}]\n`);
},
},
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;
}
},
}, },
}, },
}; };

View File

@ -1,5 +1,5 @@
export default { export default {
// TODO the rendered date needs to be localized to better match the cooked content // TODO(renato): the rendered date needs to be localized to better match the cooked content
nodeSpec: { nodeSpec: {
local_date: { local_date: {
attrs: { date: {}, time: {}, timezone: { default: null } }, attrs: { date: {}, time: {}, timezone: { default: null } },

View File

@ -1,6 +1,7 @@
import { createPopper } from "@popperjs/core"; import { createPopper } from "@popperjs/core";
import { withPluginApi } from "discourse/lib/plugin-api"; import { withPluginApi } from "discourse/lib/plugin-api";
import { iconHTML } from "discourse-common/lib/icon-library"; import { iconHTML } from "discourse-common/lib/icon-library";
import richEditorExtension from "../lib/rich-editor-extension";
let inlineFootnotePopper; let inlineFootnotePopper;
@ -128,6 +129,8 @@ export default {
tooltip?.removeAttribute("data-show"); tooltip?.removeAttribute("data-show");
tooltip?.removeAttribute("data-footnote-id"); tooltip?.removeAttribute("data-footnote-id");
}); });
api.registerRichEditorExtension(richEditorExtension);
}); });
}, },

View File

@ -0,0 +1 @@
export default {};

View File

@ -1,4 +1,5 @@
import { withPluginApi } from "discourse/lib/plugin-api"; import { withPluginApi } from "discourse/lib/plugin-api";
import richEditorExtension from "../../lib/rich-editor-extension";
import PollUiBuilder from "../components/modal/poll-ui-builder"; import PollUiBuilder from "../components/modal/poll-ui-builder";
function initializePollUIBuilder(api) { function initializePollUIBuilder(api) {
@ -21,6 +22,8 @@ function initializePollUIBuilder(api) {
); );
}, },
}); });
api.registerRichEditorExtension(richEditorExtension);
} }
export default { export default {

View File

@ -0,0 +1,104 @@
export default {
nodeSpec: {
poll: {
attrs: {
type: {},
results: {},
public: {},
name: {},
chartType: {},
close: { default: null },
groups: { default: null },
max: { default: null },
min: { default: null },
},
content: "poll_container poll_info",
group: "block",
draggable: true,
parseDOM: [
{
tag: "div.poll",
getAttrs: (dom) => ({
type: dom.getAttribute("data-poll-type"),
results: dom.getAttribute("data-poll-results"),
public: dom.getAttribute("data-poll-public"),
name: dom.getAttribute("data-poll-name"),
chartType: dom.getAttribute("data-poll-chart-type"),
close: dom.getAttribute("data-poll-close"),
groups: dom.getAttribute("data-poll-groups"),
max: dom.getAttribute("data-poll-max"),
min: dom.getAttribute("data-poll-min"),
}),
},
],
toDOM: (node) => [
"div",
{
class: "poll",
"data-poll-type": node.attrs.type,
"data-poll-results": node.attrs.results,
"data-poll-public": node.attrs.public,
"data-poll-name": node.attrs.name,
"data-poll-chart-type": node.attrs.chartType,
"data-poll-close": node.attrs.close,
"data-poll-groups": node.attrs.groups,
"data-poll-max": node.attrs.max,
"data-poll-min": node.attrs.min,
},
0,
],
},
poll_container: {
content: "heading? bullet_list",
group: "block",
parseDOM: [{ tag: "div.poll-container" }],
toDOM: () => ["div", { class: "poll-container" }, 0],
},
poll_info: {
content: "inline*",
group: "block",
atom: true,
selectable: false,
parseDOM: [{ tag: "div.poll-info" }],
toDOM: () => ["div", { class: "poll-info", contentEditable: false }, 0],
},
},
parse: {
poll: {
block: "poll",
getAttrs: (token) => ({
type: token.attrGet("data-poll-type"),
results: token.attrGet("data-poll-results"),
public: token.attrGet("data-poll-public"),
name: token.attrGet("data-poll-name"),
chartType: token.attrGet("data-poll-chart-type"),
close: token.attrGet("data-poll-close"),
groups: token.attrGet("data-poll-groups"),
max: token.attrGet("data-poll-max"),
min: token.attrGet("data-poll-min"),
}),
},
poll_container: { block: "poll_container" },
poll_title: { block: "heading" },
poll_info: { block: "poll_info" },
poll_info_counts: { ignore: true },
poll_info_counts_count: { ignore: true },
poll_info_number: { ignore: true },
poll_info_label: { ignore: true },
},
serializeNode: {
poll(state, node) {
const attrs = Object.entries(node.attrs)
.map(([key, value]) => (value ? ` ${key}="${value}"` : ""))
.join("");
state.write(`[poll${attrs}]\n`);
state.renderContent(node);
state.write("[/poll]\n\n");
},
poll_container(state, node) {
state.renderContent(node);
},
poll_info() {},
},
};

View File

@ -494,6 +494,71 @@ div.poll-outer {
} }
} }
.d-editor__editable {
.poll {
ul {
list-style-type: none;
padding: 0;
}
li {
p {
display: inline;
}
}
li:before {
position: relative;
vertical-align: baseline;
border: 2px solid var(--primary);
border-radius: 50%;
display: inline-block;
margin-right: 0.5em;
width: 12px;
height: 12px;
content: "";
}
&[data-poll-type="multiple"],
&[data-poll-type="ranked_choice"] {
display: flex;
align-items: center;
}
}
&[data-poll-type="multiple"] {
li:before {
border-radius: 3px;
}
}
&[data-poll-type="ranked_choice"] {
li {
position: relative;
&:before {
mask-image: svg-uri(
'<svg width="0.75em" height="0.75em" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--!Font Awesome Free 6.6.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path d="M201.4 374.6c12.5 12.5 32.8 12.5 45.3 0l160-160c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L224 306.7 86.6 169.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l160 160z"/></svg>'
);
z-index: 1;
width: 0.75em;
margin-right: 0.75em;
left: 0.2em;
background: var(--primary-high);
border-radius: var(--d-button-border-radius);
border: none;
}
&:after {
content: "";
position: absolute;
height: 1.125em;
width: 1.125em;
background: var(--primary-low);
border-radius: var(--d-button-border-radius);
}
}
}
}
// hides 0 vote count in crawler and print view // hides 0 vote count in crawler and print view
body.crawler { body.crawler {
.poll { .poll {
@ -503,3 +568,9 @@ body.crawler {
} }
} }
} }
.d-editor__editable .poll {
border: 1px solid var(--primary-low);
border-radius: var(--d-border-radius);
padding: 1rem;
}

View File

@ -4,6 +4,7 @@ import {
addTagDecorateCallback, addTagDecorateCallback,
} from "discourse/lib/to-markdown"; } from "discourse/lib/to-markdown";
import applySpoiler from "discourse/plugins/spoiler-alert/lib/apply-spoiler"; import applySpoiler from "discourse/plugins/spoiler-alert/lib/apply-spoiler";
import richEditorExtension from "discourse/plugins/spoiler-alert/lib/rich-editor-extension";
function spoil(element) { function spoil(element) {
element.querySelectorAll(".spoiler").forEach((spoiler) => { element.querySelectorAll(".spoiler").forEach((spoiler) => {
@ -45,6 +46,8 @@ export function initializeSpoiler(api) {
return text.trim(); return text.trim();
} }
}); });
api.registerRichEditorExtension(richEditorExtension);
} }
export default { export default {

View File

@ -0,0 +1,66 @@
const INLINE_NODES = ["inline_spoiler", "spoiler"];
export default {
nodeSpec: {
spoiler: {
attrs: { blurred: { default: true } },
group: "block",
content: "block+",
defining: true,
parseDOM: [{ tag: "div.spoiled" }],
toDOM: (node) => [
"div",
{ class: `spoiled ${node.attrs.blurred ? "spoiler-blurred" : ""}` },
0,
],
},
inline_spoiler: {
attrs: { blurred: { default: true } },
group: "inline",
inline: true,
content: "inline*",
parseDOM: [{ tag: "span.spoiled" }],
toDOM: (node) => [
"span",
{ class: `spoiled ${node.attrs.blurred ? "spoiler-blurred" : ""}` },
0,
],
},
},
parse: {
bbcode_spoiler: { block: "inline_spoiler" },
wrap_bbcode(state, token) {
if (token.nesting === 1 && token.attrGet("class") === "spoiler") {
state.openNode(state.schema.nodes.spoiler);
} else if (token.nesting === -1) {
state.closeNode();
}
},
},
serializeNode: {
spoiler(state, node) {
state.write("[spoiler]\n");
state.renderContent(node);
state.write("[/spoiler]\n\n");
},
inline_spoiler(state, node) {
state.write("[spoiler]");
state.renderInline(node);
state.write("[/spoiler]");
},
},
plugins: {
props: {
handleClickOn(view, pos, node, nodePos, event, direct) {
if (INLINE_NODES.includes(node.type.name)) {
view.dispatch(
view.state.tr.setNodeMarkup(nodePos, null, {
blurred: !node.attrs.blurred,
})
);
return true;
}
},
},
},
};

View File

@ -52,3 +52,12 @@
} }
} }
} }
.d-editor__editable {
.spoiled:not(.spoiler-blurred) {
box-shadow: 0 0 4px 4px rgba(var(--primary-rgb), 0.2);
inline-size: max-content;
padding-left: 2px;
padding-right: 2px;
}
}