mirror of
https://github.com/discourse/discourse.git
synced 2026-08-06 13:08:40 +08:00
Replaces the custom ~1000-line `to-markdown` converter with ProseMirror's schema-based parsing and serialization pipeline, reusing the same extensions that power the rich editor. The old converter duplicated logic the extensions already implement (image formatting, mention/hashtag serialization, table handling, list nesting, etc.) — a single pipeline means one set of rules, fewer divergence bugs, and plugin extensions work in both directions automatically. ### Core change - `toMarkdown()` is now async and lazy-loads ProseMirror on first use. - Cooked→markdown serialization runs through the registered rich-editor extensions, so both conversion directions share one source of truth. ### QuoteState and quote callers Making `toMarkdown()` async changed the quote flow. `QuoteState` now stores the selection's HTML and exposes an async `markdown()`; `buffer` is the plain-text selection. Every caller that needs markdown was updated to await `markdown()`: - Topic controller (`selectText`, `replyToPost` including EmbedMode, `replyAsNewTopic`, `buildQuoteMarkdown`). - Nested controller — forwards the full selection state via the new `copyFrom`. - Fast-edit modal and `computeSupportsFastEdit`. - discourse-ai post-helper menu — now sends markdown (not plain text) to the AI endpoints. `markdown()` snapshots `opts` before awaiting so a concurrent selection change can't mis-pair the result. ### Plugin API - Removed plugin-specific `addTagDecorateCallback`/`addTextDecorateCallback` usage from local-dates and spoiler-alert (the old APIs are kept as deprecated no-ops). local-dates now uses a `transformParsedHTML` rich-editor extension hook. ### Extractions and sharing - Word paste handling moved into a dedicated `word-paste.js` extension. - Quote-selection list-structure preservation moved into `preserve-list-structure.js`. - `normalizeTable` is shared between the paste plugin and the serializer for consistent table output across editor modes. ### Quoting fix - `selectedHTML` drops the empty trailing block a triple-click leaves behind. A triple-click extends the selection's end to the start of the following block, so `cloneContents()` would otherwise clone an empty `<blockquote>` that serializes to a stray `> ` in the quote.
263 lines
6.6 KiB
JavaScript
Vendored
263 lines
6.6 KiB
JavaScript
Vendored
import { iconHTML } from "discourse/lib/icon-library";
|
|
import { i18n } from "discourse-i18n";
|
|
import MathEditModal from "discourse/plugins/discourse-math/discourse/components/modal/math-edit";
|
|
import {
|
|
buildDiscourseMathOptions,
|
|
renderMathInElement,
|
|
} from "./math-renderer";
|
|
|
|
const createMathNodeView =
|
|
({ getContext, pmState: { NodeSelection } }) =>
|
|
(node, view, getPos) =>
|
|
new MathNodeView({ node, view, getPos, getContext, NodeSelection });
|
|
|
|
function escapeDelimiter(text, delimiter) {
|
|
if (!text) {
|
|
return "";
|
|
}
|
|
|
|
let result = "";
|
|
|
|
for (let i = 0; i < text.length; i++) {
|
|
const char = text[i];
|
|
if (char === delimiter) {
|
|
let backslashes = 0;
|
|
let j = i - 1;
|
|
while (j >= 0 && text[j] === "\\") {
|
|
backslashes++;
|
|
j--;
|
|
}
|
|
if (backslashes % 2 === 0) {
|
|
result += "\\";
|
|
}
|
|
}
|
|
result += char;
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
class MathNodeView {
|
|
node;
|
|
view;
|
|
getPos;
|
|
getContext;
|
|
NodeSelection;
|
|
dom;
|
|
editButton;
|
|
content;
|
|
|
|
openEditModal = (event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
|
|
const { modal } = this.getContext();
|
|
modal.show(MathEditModal, {
|
|
model: {
|
|
initialText: this.node.attrs.text ?? "",
|
|
isBlock: !this.node.isInline,
|
|
mathType: this.node.attrs.mathType ?? "tex",
|
|
onApply: (text) => this.#applyEdit(text),
|
|
},
|
|
});
|
|
};
|
|
|
|
constructor({ node, view, getPos, getContext, NodeSelection }) {
|
|
this.node = node;
|
|
this.view = view;
|
|
this.getPos = getPos;
|
|
this.getContext = getContext;
|
|
this.NodeSelection = NodeSelection;
|
|
|
|
const isInline = node.isInline;
|
|
this.dom = document.createElement(isInline ? "span" : "div");
|
|
this.dom.classList.add("composer-math-node");
|
|
|
|
this.editButton = document.createElement("button");
|
|
this.editButton.type = "button";
|
|
this.editButton.classList.add("btn-flat", "math-node-edit-button");
|
|
this.editButton.setAttribute("contenteditable", "false");
|
|
this.editButton.setAttribute("title", i18n("discourse_math.edit_math"));
|
|
this.editButton.setAttribute(
|
|
"aria-label",
|
|
i18n("discourse_math.edit_math")
|
|
);
|
|
this.editButton.innerHTML = iconHTML("pencil");
|
|
this.editButton.addEventListener("click", this.openEditModal);
|
|
|
|
this.content = document.createElement(isInline ? "span" : "div");
|
|
this.content.classList.add("math-node-content");
|
|
this.content.setAttribute("contenteditable", "false");
|
|
|
|
this.dom.appendChild(this.editButton);
|
|
this.dom.appendChild(this.content);
|
|
|
|
this.#syncContent();
|
|
this.#renderMath(true);
|
|
}
|
|
|
|
update(node) {
|
|
const contentChanged =
|
|
node.attrs.text !== this.node.attrs.text ||
|
|
node.attrs.mathType !== this.node.attrs.mathType;
|
|
this.node = node;
|
|
|
|
if (contentChanged) {
|
|
this.#syncContent();
|
|
this.#renderMath(true);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
selectNode() {
|
|
this.dom.classList.add("ProseMirror-selectednode");
|
|
}
|
|
|
|
deselectNode() {
|
|
this.dom.classList.remove("ProseMirror-selectednode");
|
|
}
|
|
|
|
stopEvent(event) {
|
|
return event.target instanceof Node
|
|
? this.editButton.contains(event.target)
|
|
: false;
|
|
}
|
|
|
|
ignoreMutation() {
|
|
return true;
|
|
}
|
|
|
|
destroy() {
|
|
this.editButton.removeEventListener("click", this.openEditModal);
|
|
}
|
|
|
|
#syncContent() {
|
|
const isAscii =
|
|
this.node.isInline && this.node.attrs.mathType === "asciimath";
|
|
|
|
this.content.classList.toggle("asciimath", isAscii);
|
|
this.content.classList.toggle("math", !isAscii);
|
|
this.content.textContent = this.node.attrs.text ?? "";
|
|
}
|
|
|
|
#renderMath(force = false) {
|
|
const options = buildDiscourseMathOptions(this.getContext().siteSettings);
|
|
renderMathInElement(this.dom, options, { force });
|
|
}
|
|
|
|
#applyEdit(text) {
|
|
const pos = this.getPos();
|
|
const attrs = { ...this.node.attrs, text };
|
|
const tr = this.view.state.tr.setNodeMarkup(pos, null, attrs);
|
|
tr.setSelection(this.NodeSelection.create(tr.doc, pos));
|
|
this.view.dispatch(tr);
|
|
}
|
|
}
|
|
|
|
const extension = {
|
|
nodeViews: {
|
|
math_inline: createMathNodeView,
|
|
math_block: createMathNodeView,
|
|
},
|
|
nodeSpec: {
|
|
math_inline: {
|
|
inline: true,
|
|
group: "inline",
|
|
atom: true,
|
|
selectable: true,
|
|
draggable: true,
|
|
attrs: {
|
|
text: { default: "" },
|
|
mathType: { default: "tex" },
|
|
},
|
|
parseDOM: [
|
|
{
|
|
tag: "span.math",
|
|
getAttrs: (dom) => ({
|
|
text: dom.textContent.trim(),
|
|
mathType: "tex",
|
|
}),
|
|
},
|
|
{
|
|
tag: "span.asciimath",
|
|
getAttrs: (dom) => ({
|
|
text: dom.textContent.trim(),
|
|
mathType: "asciimath",
|
|
}),
|
|
},
|
|
],
|
|
toDOM: (node) => [
|
|
"span",
|
|
{ class: node.attrs.mathType === "asciimath" ? "asciimath" : "math" },
|
|
node.attrs.text,
|
|
],
|
|
},
|
|
math_block: {
|
|
group: "block",
|
|
atom: true,
|
|
selectable: true,
|
|
defining: true,
|
|
isolating: true,
|
|
attrs: {
|
|
text: { default: "" },
|
|
mathType: { default: "tex" },
|
|
},
|
|
parseDOM: [
|
|
{
|
|
tag: "div.math",
|
|
getAttrs: (dom) => ({
|
|
text: dom.textContent.trim(),
|
|
mathType: "tex",
|
|
}),
|
|
},
|
|
],
|
|
toDOM: (node) => ["div", { class: "math" }, node.attrs.text],
|
|
},
|
|
},
|
|
parse: {
|
|
math_inline: {
|
|
node: "math_inline",
|
|
getAttrs: (token) => ({
|
|
text: token.content,
|
|
mathType: token.meta?.mathType || "tex",
|
|
}),
|
|
},
|
|
math_block: {
|
|
node: "math_block",
|
|
getAttrs: (token) => ({
|
|
text: token.content,
|
|
mathType: token.meta?.mathType || "tex",
|
|
}),
|
|
},
|
|
},
|
|
serializeNode({ utils: { isBoundary } }) {
|
|
return {
|
|
math_inline(state, node, parent, index) {
|
|
state.flushClose();
|
|
if (!isBoundary(state.out, state.out.length - 1)) {
|
|
state.write(" ");
|
|
}
|
|
|
|
const delimiter = node.attrs.mathType === "asciimath" ? "%" : "$";
|
|
const content = escapeDelimiter(node.attrs.text ?? "", delimiter);
|
|
state.write(`${delimiter}${content}${delimiter}`);
|
|
|
|
const nextSibling =
|
|
parent.childCount > index + 1 ? parent.child(index + 1) : null;
|
|
if (nextSibling?.isText && !isBoundary(nextSibling.text, 0)) {
|
|
state.write(" ");
|
|
}
|
|
},
|
|
math_block(state, node) {
|
|
state.ensureNewLine();
|
|
const content = escapeDelimiter(node.attrs.text ?? "", "$");
|
|
state.write("$$\n");
|
|
state.write(content);
|
|
state.write("\n$$\n\n");
|
|
},
|
|
};
|
|
},
|
|
};
|
|
|
|
export default extension;
|