FEATURE: add local-dates (plugin) rich editor extension (#31714)

Continues the work done on
https://github.com/discourse/discourse/pull/30815.

Adds `local_date` and `local_date_range` nodes, parsers, and
serializers.
This commit is contained in:
Renato Atilio
2025-03-11 20:13:09 -03:00
committed by GitHub
parent d56e69e7c0
commit d1870d4811
7 changed files with 319 additions and 11 deletions

View File

@ -13,6 +13,7 @@ import { i18n } from "discourse-i18n";
import generateDateMarkup from "discourse/plugins/discourse-local-dates/lib/local-date-markup-generator";
import LocalDatesCreateModal from "../discourse/components/modal/local-dates-create";
import LocalDateBuilder from "../lib/local-date-builder";
import richEditorExtension from "../lib/rich-editor-extension";
// Import applyLocalDates from discourse/lib/local-dates instead
export function applyLocalDates(dates, siteSettings) {
@ -142,6 +143,8 @@ function _partitionedRanges(element) {
}
function initializeDiscourseLocalDates(api) {
api.registerRichEditorExtension(richEditorExtension);
const modal = api.container.lookup("service:modal");
const siteSettings = api.container.lookup("service:site-settings");
const defaultTitle = i18n("discourse_local_dates.default_title", {

View File

@ -0,0 +1,195 @@
/** @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) {
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) {
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;

View File

@ -0,0 +1,58 @@
# frozen_string_literal: true
describe "Composer - ProseMirror editor - Local Dates extension", type: :system do
fab!(:user) { Fabricate(:user, refresh_auto_groups: true) }
let(:cdp) { PageObjects::CDP.new }
let(:composer) { PageObjects::Components::Composer.new }
let(:rich) { composer.rich_editor }
before do
sign_in(user)
SiteSetting.rich_editor = true
end
def open_composer_and_toggle_rich_editor
page.visit "/new-topic"
expect(composer).to be_opened
composer.toggle_rich_editor
end
describe "pasting content" do
it "converts a single date bbcode to a local_date node" do
cdp.allow_clipboard
open_composer_and_toggle_rich_editor
rich.click
cdp.write_clipboard <<~MARKDOWN
[date=2022-12-15 time=14:19:00 timezone="Asia/Singapore"]
MARKDOWN
page.send_keys([SystemHelpers::PLATFORM_KEY_MODIFIER, "v"])
expect(rich).to have_css(
"span.discourse-local-date[data-timezone='Asia/Singapore']",
text: "2022-12-15 14:19:00",
)
end
it "converts a date range bbcode to a local_date_range node" do
cdp.allow_clipboard
open_composer_and_toggle_rich_editor
rich.click
cdp.write_clipboard <<~MARKDOWN
[date-range from=2022-12-15T14:19:00 to=2022-12-16T15:20:00 timezone="Asia/Singapore"]
MARKDOWN
page.send_keys([SystemHelpers::PLATFORM_KEY_MODIFIER, "v"])
expect(rich).to have_css("span.discourse-local-date-range")
expect(rich).to have_css(
"span.discourse-local-date[data-timezone='Asia/Singapore'][data-range='from']",
text: "2022-12-15 14:19:00",
)
expect(rich).to have_css(
"span.discourse-local-date[data-timezone='Asia/Singapore'][data-range='to']",
text: "2022-12-16 15:20:00",
)
end
end
end

View File

@ -0,0 +1,38 @@
import { module, test } from "qunit";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import { testMarkdown } from "discourse/tests/helpers/rich-editor-helper";
module(
"Integration | Component | prosemirror-editor - local-dates plugin extension",
function (hooks) {
setupRenderingTest(hooks);
Object.entries({
"local date": [
"[date=2021-01-01 time=12:00:00]",
'<p><span class="discourse-local-date cooked-date" data-date="2021-01-01" data-time="12:00:00" contenteditable="false">2021-01-01 12:00:00</span></p>',
"[date=2021-01-01 time=12:00:00]",
],
"local date with timezone": [
'[date=2021-01-01 time=12:00:00 timezone="America/New_York"]',
'<p><span class="discourse-local-date cooked-date" data-date="2021-01-01" data-time="12:00:00" data-timezone="America/New_York" contenteditable="false">2021-01-01 12:00:00</span></p>',
'[date=2021-01-01 time=12:00:00 timezone="America/New_York"]',
],
"local date range": [
"[date-range from=2021-01-01 to=2021-01-02]",
'<p><span class="discourse-local-date-range" contenteditable="false"><span class="discourse-local-date cooked-date" data-range="from" data-date="2021-01-01">2021-01-01</span> → <span class="discourse-local-date cooked-date" data-range="to" data-date="2021-01-02">2021-01-02</span></span></p>',
"[date-range from=2021-01-01 to=2021-01-02]",
],
"local date range with time": [
'[date-range from=2021-01-01T12:00:00 to=2021-01-02T13:00:00 timezone="America/New_York"]',
'<p><span class="discourse-local-date-range" contenteditable="false"><span class="discourse-local-date cooked-date" data-range="from" data-date="2021-01-01" data-time="12:00:00" data-timezone="America/New_York">2021-01-01 12:00:00</span> → <span class="discourse-local-date cooked-date" data-range="to" data-date="2021-01-02" data-time="13:00:00" data-timezone="America/New_York">2021-01-02 13:00:00</span></span></p>',
'[date-range from=2021-01-01T12:00:00 to=2021-01-02T13:00:00 timezone="America/New_York"]',
],
}).forEach(([name, [markdown, html, expectedMarkdown]]) => {
test(name, async function (assert) {
this.siteSettings.rich_editor = true;
await testMarkdown(assert, markdown, html, expectedMarkdown);
});
});
}
);