Files
discourse/plugins/discourse-local-dates/assets/javascripts/lib/rich-editor-extension.js
Renato Atilio b3f0c85d82 UX: avoid leading space when serializing some nodes from rich editor (#32270)
On some cases during serialization (like with headings), the previous
node is still "open" when it's the "turn" of the node we're serializing.

In this case, checking the boundaries before writing was getting the
wrong state, because the `write` call uses `flushClose()`, which makes
sure we have the newlines from closing the previous node.

This would create scenarios like (notice the space before each node
below the heading)

```markdown
# Heading

 🎉

# Heading

 @mention

# Heading

  #hashtag
```

This PR makes sure we call flushClose() before checking boundaries, and
adds tests for that.
2025-04-14 10:57:34 -03:00

198 lines
6.0 KiB
JavaScript

/** @type {RichEditorExtension} */
const extension = {
// TODO(renato): the rendered date needs to be localized to better match the cooked content
nodeSpec: {
local_date: {
attrs: { date: {}, time: {}, timezone: { default: null } },
group: "inline",
atom: true,
inline: true,
parseDOM: [
{
tag: "span.discourse-local-date[data-date]",
getAttrs: (dom) => {
return {
date: dom.getAttribute("data-date"),
time: dom.getAttribute("data-time"),
timezone: dom.getAttribute("data-timezone"),
};
},
},
],
toDOM: (node) => {
const optionalTime = node.attrs.time ? ` ${node.attrs.time}` : "";
return [
"span",
{
class: "discourse-local-date cooked-date",
"data-date": node.attrs.date,
"data-time": node.attrs.time,
"data-timezone": node.attrs.timezone,
},
`${node.attrs.date}${optionalTime}`,
];
},
},
local_date_range: {
attrs: {
fromDate: {},
toDate: { default: null },
fromTime: {},
toTime: {},
timezone: { default: null },
},
group: "inline",
atom: true,
inline: true,
parseDOM: [
{
tag: "span.discourse-local-date-range",
getAttrs: (dom) => {
return {
fromDate: dom.dataset.fromDate,
toDate: dom.dataset.toDate,
fromTime: dom.dataset.fromTime,
toTime: dom.dataset.toTime,
timezone: dom.dataset.timezone,
};
},
},
],
toDOM: (node) => {
const fromTimeStr = node.attrs.fromTime
? ` ${node.attrs.fromTime}`
: "";
const toTimeStr = node.attrs.toTime ? ` ${node.attrs.toTime}` : "";
return [
"span",
{ class: "discourse-local-date-range" },
[
"span",
{
class: "discourse-local-date cooked-date",
"data-range": "from",
"data-date": node.attrs.fromDate,
"data-time": node.attrs.fromTime,
"data-timezone": node.attrs.timezone,
},
`${node.attrs.fromDate}${fromTimeStr}`,
],
" → ",
[
"span",
{
class: "discourse-local-date cooked-date",
"data-range": "to",
"data-date": node.attrs.toDate,
"data-time": node.attrs.toTime,
"data-timezone": node.attrs.timezone,
},
`${node.attrs.toDate}${toTimeStr}`,
],
];
},
},
},
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"),
});
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"),
});
// 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 optionalTime = node.attrs.time ? ` time=${node.attrs.time}` : "";
const optionalTimezone = node.attrs.timezone
? ` timezone="${node.attrs.timezone}"`
: "";
state.write(
`[date=${node.attrs.date}${optionalTime}${optionalTimezone}]`
);
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 optionalTimezone = node.attrs.timezone
? ` timezone="${node.attrs.timezone}"`
: "";
const from =
node.attrs.fromDate +
(node.attrs.fromTime ? `T${node.attrs.fromTime}` : "");
const to =
node.attrs.toDate +
(node.attrs.toTime ? `T${node.attrs.toTime}` : "");
state.write(`[date-range from=${from} to=${to}${optionalTimezone}]`);
const nextSibling =
parent.childCount > index + 1 ? parent.child(index + 1) : null;
if (nextSibling?.isText && !isBoundary(nextSibling.text, 0)) {
state.write(" ");
}
},
};
},
};
export default extension;