0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-09 21:45:25 +08:00
discourse/plugins/discourse-local-dates/assets/javascripts/lib/rich-editor-extension.js
Renato Atilio d62cc48d9c
DEV: to-markdown prosemirror HTML parsing (#35563)
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.
2026-05-29 18:14:32 -03:00

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;