mirror of
https://github.com/discourse/discourse.git
synced 2026-08-09 21:45:25 +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.
345 lines
11 KiB
JavaScript
Vendored
345 lines
11 KiB
JavaScript
Vendored
import { buildBBCodeAttrs } from "discourse/lib/text";
|
|
import formatLocalDate from "./format-local-date";
|
|
|
|
function wrapDateRangeSpans(element) {
|
|
const dateSpans = element.querySelectorAll(
|
|
"span.discourse-local-date[data-range]"
|
|
);
|
|
const processed = new Set();
|
|
|
|
for (const span of dateSpans) {
|
|
if (processed.has(span) || span.closest(".discourse-local-date-range")) {
|
|
continue;
|
|
}
|
|
|
|
const range = span.dataset.range;
|
|
if (range !== "true" && range !== "from") {
|
|
continue;
|
|
}
|
|
|
|
let toSpan = span.nextSibling;
|
|
while (toSpan && toSpan.nodeType === Node.TEXT_NODE) {
|
|
toSpan = toSpan.nextSibling;
|
|
}
|
|
|
|
if (
|
|
!toSpan?.classList?.contains("discourse-local-date") ||
|
|
!toSpan.dataset?.range
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
processed.add(span);
|
|
processed.add(toSpan);
|
|
|
|
span.dataset.range = "from";
|
|
toSpan.dataset.range = "to";
|
|
|
|
const doc =
|
|
element.nodeType === Node.DOCUMENT_NODE ? element : element.ownerDocument;
|
|
const wrapper = doc.createElement("span");
|
|
wrapper.className = "discourse-local-date-range";
|
|
span.parentNode.insertBefore(wrapper, span);
|
|
wrapper.appendChild(span);
|
|
|
|
let node = wrapper.nextSibling;
|
|
while (node && node !== toSpan) {
|
|
const next = node.nextSibling;
|
|
wrapper.appendChild(node);
|
|
node = next;
|
|
}
|
|
|
|
wrapper.appendChild(toSpan);
|
|
}
|
|
}
|
|
|
|
const OPTIONAL_DATA_ATTRS = [
|
|
"format",
|
|
"recurring",
|
|
"timezones",
|
|
"countdown",
|
|
"displayedTimezone",
|
|
];
|
|
|
|
/**
|
|
* Adds optional data attributes to a DOM attributes object
|
|
* @param {Object} attrs - The attributes object to modify
|
|
* @param {Object} nodeAttrs - The node attributes to read from
|
|
* @param {string[]} keys - The keys to process
|
|
*/
|
|
function addOptionalDataAttrs(attrs, nodeAttrs, keys = OPTIONAL_DATA_ATTRS) {
|
|
for (const key of keys) {
|
|
if (nodeAttrs[key]) {
|
|
const dataKey =
|
|
key === "displayedTimezone" ? "data-displayed-timezone" : `data-${key}`;
|
|
attrs[dataKey] = nodeAttrs[key];
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Builds format options from node attributes
|
|
* @param {Object} nodeAttrs - The node attributes
|
|
* @param {boolean} includeRecurring - Whether to include the recurring option
|
|
* @returns {Object} The options object for formatLocalDate
|
|
*/
|
|
function buildFormatOptions(nodeAttrs, includeRecurring = false) {
|
|
const options = {
|
|
format: nodeAttrs.format,
|
|
countdown: nodeAttrs.countdown,
|
|
displayedTimezone: nodeAttrs.displayedTimezone,
|
|
timezones: nodeAttrs.timezones?.split("|"),
|
|
};
|
|
if (includeRecurring) {
|
|
options.recurring = nodeAttrs.recurring;
|
|
}
|
|
return options;
|
|
}
|
|
|
|
/** @type {RichEditorExtension} */
|
|
const extension = {
|
|
nodeSpec: {
|
|
local_date: {
|
|
attrs: {
|
|
date: {},
|
|
time: { default: null },
|
|
timezone: { default: null },
|
|
format: { default: null },
|
|
recurring: { default: null },
|
|
timezones: { default: null },
|
|
countdown: { default: null },
|
|
displayedTimezone: { default: null },
|
|
},
|
|
group: "inline",
|
|
inline: true,
|
|
parseDOM: [
|
|
{
|
|
tag: "span.discourse-local-date[data-date]",
|
|
getAttrs: (dom) => {
|
|
// Skip spans that are part of a range (handled by local_date_range via wrapper)
|
|
if (dom.dataset.range) {
|
|
return false;
|
|
}
|
|
return {
|
|
date: dom.dataset.date,
|
|
time: dom.dataset.time,
|
|
timezone: dom.dataset.timezone,
|
|
format: dom.dataset.format,
|
|
recurring: dom.dataset.recurring,
|
|
timezones: dom.dataset.timezones,
|
|
countdown: dom.dataset.countdown,
|
|
displayedTimezone: dom.dataset.displayedTimezone,
|
|
};
|
|
},
|
|
},
|
|
],
|
|
toDOM: (node) => {
|
|
const options = buildFormatOptions(node.attrs, true);
|
|
const { formatted } = formatLocalDate(
|
|
node.attrs.date,
|
|
node.attrs.time,
|
|
node.attrs.timezone,
|
|
options
|
|
);
|
|
const attrs = {
|
|
class: "discourse-local-date cooked-date",
|
|
"data-date": node.attrs.date,
|
|
"data-time": node.attrs.time,
|
|
"data-timezone": node.attrs.timezone,
|
|
};
|
|
addOptionalDataAttrs(attrs, node.attrs);
|
|
return ["span", attrs, formatted];
|
|
},
|
|
},
|
|
local_date_range: {
|
|
attrs: {
|
|
fromDate: {},
|
|
toDate: { default: null },
|
|
fromTime: { default: null },
|
|
toTime: { default: null },
|
|
timezone: { default: null },
|
|
format: { default: null },
|
|
timezones: { default: null },
|
|
countdown: { default: null },
|
|
displayedTimezone: { default: null },
|
|
},
|
|
group: "inline",
|
|
inline: true,
|
|
parseDOM: [
|
|
{
|
|
tag: "span.discourse-local-date-range",
|
|
getAttrs: (dom) => {
|
|
const fromSpan = dom.querySelector('[data-range="from"]');
|
|
const toSpan = dom.querySelector('[data-range="to"]');
|
|
if (!fromSpan) {
|
|
return false;
|
|
}
|
|
return {
|
|
fromDate: fromSpan.dataset.date,
|
|
toDate: toSpan?.dataset.date,
|
|
fromTime: fromSpan.dataset.time,
|
|
toTime: toSpan?.dataset.time,
|
|
timezone: fromSpan.dataset.timezone,
|
|
format: fromSpan.dataset.format,
|
|
timezones: fromSpan.dataset.timezones,
|
|
countdown: fromSpan.dataset.countdown,
|
|
displayedTimezone: fromSpan.dataset.displayedTimezone,
|
|
};
|
|
},
|
|
},
|
|
],
|
|
toDOM: (node) => {
|
|
const options = buildFormatOptions(node.attrs);
|
|
const { formatted: formattedFrom } = formatLocalDate(
|
|
node.attrs.fromDate,
|
|
node.attrs.fromTime,
|
|
node.attrs.timezone,
|
|
options
|
|
);
|
|
const { formatted: formattedTo } = formatLocalDate(
|
|
node.attrs.toDate,
|
|
node.attrs.toTime,
|
|
node.attrs.timezone,
|
|
options
|
|
);
|
|
const rangeAttrs = [
|
|
"format",
|
|
"timezones",
|
|
"countdown",
|
|
"displayedTimezone",
|
|
];
|
|
const fromAttrs = {
|
|
class: "discourse-local-date cooked-date",
|
|
"data-range": "from",
|
|
"data-date": node.attrs.fromDate,
|
|
"data-time": node.attrs.fromTime,
|
|
"data-timezone": node.attrs.timezone,
|
|
};
|
|
const toAttrs = {
|
|
class: "discourse-local-date cooked-date",
|
|
"data-range": "to",
|
|
"data-date": node.attrs.toDate,
|
|
"data-time": node.attrs.toTime,
|
|
"data-timezone": node.attrs.timezone,
|
|
};
|
|
addOptionalDataAttrs(fromAttrs, node.attrs, rangeAttrs);
|
|
addOptionalDataAttrs(toAttrs, node.attrs, rangeAttrs);
|
|
return [
|
|
"span",
|
|
{ class: "discourse-local-date-range" },
|
|
["span", fromAttrs, formattedFrom],
|
|
" → ",
|
|
["span", toAttrs, formattedTo],
|
|
];
|
|
},
|
|
},
|
|
},
|
|
parse: {
|
|
span_open(state, token, tokens, i) {
|
|
if (token.attrGet("class") !== "discourse-local-date") {
|
|
return;
|
|
}
|
|
|
|
if (token.attrGet("data-range") === "from") {
|
|
state.openNode(state.schema.nodes.local_date_range, {
|
|
fromDate: token.attrGet("data-date"),
|
|
fromTime: token.attrGet("data-time"),
|
|
timezone: token.attrGet("data-timezone"),
|
|
format: token.attrGet("data-format"),
|
|
timezones: token.attrGet("data-timezones"),
|
|
countdown: token.attrGet("data-countdown"),
|
|
displayedTimezone: token.attrGet("data-displayed-timezone"),
|
|
});
|
|
state.__localDateRange = true;
|
|
// we depend on the token data being strictly:
|
|
// [span_open, text, span_close, text, span_open, text, span_close]
|
|
// removing the text occurrences
|
|
tokens.splice(i + 1, 1);
|
|
tokens.splice(i + 2, 1);
|
|
tokens.splice(i + 3, 1);
|
|
|
|
return true;
|
|
}
|
|
|
|
if (token.attrGet("data-range") === "to") {
|
|
// In our markdown-it tokens, a range is a series of span_open/span_close/span_open/span_close
|
|
// We skip opening a node for `to` and set it on the top node
|
|
state.top().attrs.toDate = token.attrGet("data-date");
|
|
state.top().attrs.toTime = token.attrGet("data-time");
|
|
delete state.__localDateRange;
|
|
return true;
|
|
}
|
|
|
|
state.openNode(state.schema.nodes.local_date, {
|
|
date: token.attrGet("data-date"),
|
|
time: token.attrGet("data-time"),
|
|
timezone: token.attrGet("data-timezone"),
|
|
format: token.attrGet("data-format"),
|
|
recurring: token.attrGet("data-recurring"),
|
|
timezones: token.attrGet("data-timezones"),
|
|
countdown: token.attrGet("data-countdown"),
|
|
displayedTimezone: token.attrGet("data-displayed-timezone"),
|
|
});
|
|
// removing the text occurrence
|
|
tokens.splice(i + 1, 1);
|
|
return true;
|
|
},
|
|
span_close(state) {
|
|
if (["local_date", "local_date_range"].includes(state.top().type.name)) {
|
|
if (!state.__localDateRange) {
|
|
state.closeNode();
|
|
}
|
|
return true;
|
|
}
|
|
},
|
|
},
|
|
serializeNode({ utils: { isBoundary } }) {
|
|
return {
|
|
local_date(state, node, parent, index) {
|
|
state.flushClose();
|
|
if (!isBoundary(state.out, state.out.length - 1)) {
|
|
state.write(" ");
|
|
}
|
|
|
|
const { date, ...rest } = node.attrs;
|
|
const optionalAttrs = buildBBCodeAttrs(rest);
|
|
state.write(
|
|
`[date=${date}${optionalAttrs ? ` ${optionalAttrs}` : ""}]`
|
|
);
|
|
|
|
const nextSibling =
|
|
parent.childCount > index + 1 ? parent.child(index + 1) : null;
|
|
if (nextSibling?.isText && !isBoundary(nextSibling.text, 0)) {
|
|
state.write(" ");
|
|
}
|
|
},
|
|
local_date_range(state, node, parent, index) {
|
|
state.flushClose();
|
|
if (!isBoundary(state.out, state.out.length - 1)) {
|
|
state.write(" ");
|
|
}
|
|
|
|
const { fromDate, toDate, fromTime, toTime, ...rest } = node.attrs;
|
|
const from = fromDate + (fromTime ? `T${fromTime}` : "");
|
|
const to = toDate + (toTime ? `T${toTime}` : "");
|
|
const optionalAttrs = buildBBCodeAttrs(rest);
|
|
state.write(
|
|
`[date-range from=${from} to=${to}${optionalAttrs ? ` ${optionalAttrs}` : ""}]`
|
|
);
|
|
|
|
const nextSibling =
|
|
parent.childCount > index + 1 ? parent.child(index + 1) : null;
|
|
if (nextSibling?.isText && !isBoundary(nextSibling.text, 0)) {
|
|
state.write(" ");
|
|
}
|
|
},
|
|
};
|
|
},
|
|
|
|
// Pre-process HTML to wrap adjacent date-range spans before parsing
|
|
transformParsedHTML(element) {
|
|
wrapDateRangeSpans(element);
|
|
},
|
|
};
|
|
|
|
export default extension;
|