mirror of
https://github.com/discourse/discourse.git
synced 2025-05-25 00:32:52 +08:00
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:
@ -53,6 +53,9 @@
|
|||||||
/** @typedef {((params: PluginParams) => KeymapSpec)} RichKeymapFn */
|
/** @typedef {((params: PluginParams) => KeymapSpec)} RichKeymapFn */
|
||||||
/** @typedef {KeymapSpec | RichKeymapFn} RichKeymap */
|
/** @typedef {KeymapSpec | RichKeymapFn} RichKeymap */
|
||||||
|
|
||||||
|
// @ts-ignore MarkSerializerSpec not currently exported
|
||||||
|
/** @typedef {import('prosemirror-markdown').MarkSerializerSpec} MarkSerializerSpec */
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {Object} RichEditorExtension
|
* @typedef {Object} RichEditorExtension
|
||||||
* @property {Record<string, import('prosemirror-model').NodeSpec>} [nodeSpec]
|
* @property {Record<string, import('prosemirror-model').NodeSpec>} [nodeSpec]
|
||||||
@ -64,10 +67,9 @@
|
|||||||
* @property {RichInputRule | Array<RichInputRule>} [inputRules]
|
* @property {RichInputRule | Array<RichInputRule>} [inputRules]
|
||||||
* ProseMirror input rules. See https://prosemirror.net/docs/ref/#inputrules.InputRule
|
* ProseMirror input rules. See https://prosemirror.net/docs/ref/#inputrules.InputRule
|
||||||
* can be a function returning an array or an array of input rules
|
* can be a function returning an array or an array of input rules
|
||||||
* @property {Record<string, SerializeNodeFn>} [serializeNode]
|
* @property {(params: PluginParams) => Record<string, SerializeNodeFn> | Record<string, SerializeNodeFn>} [serializeNode]
|
||||||
* Node serialization definition
|
* Node serialization definition
|
||||||
* @ts-ignore MarkSerializerSpec not currently exported
|
* @property {(params: PluginParams) => Record<string, MarkSerializerSpec> | Record<string, MarkSerializerSpec>} [serializeMark]
|
||||||
* @property {Record<string, import('prosemirror-markdown').MarkSerializerSpec>} [serializeMark]
|
|
||||||
* Mark serialization definition
|
* Mark serialization definition
|
||||||
* @property {Record<string, RichParseSpec>} [parse]
|
* @property {Record<string, RichParseSpec>} [parse]
|
||||||
* Markdown-it token parse definition
|
* Markdown-it token parse definition
|
||||||
|
@ -144,7 +144,11 @@ export default class ProsemirrorEditor extends Component {
|
|||||||
];
|
];
|
||||||
|
|
||||||
this.parser = new Parser(this.extensions, this.args.includeDefault);
|
this.parser = new Parser(this.extensions, this.args.includeDefault);
|
||||||
this.serializer = new Serializer(this.extensions, this.args.includeDefault);
|
this.serializer = new Serializer(
|
||||||
|
this.extensions,
|
||||||
|
this.pluginParams,
|
||||||
|
this.args.includeDefault
|
||||||
|
);
|
||||||
|
|
||||||
const state = EditorState.create({ schema: this.schema, plugins });
|
const state = EditorState.create({ schema: this.schema, plugins });
|
||||||
|
|
||||||
|
@ -6,14 +6,14 @@ import {
|
|||||||
export default class Serializer {
|
export default class Serializer {
|
||||||
#pmSerializer;
|
#pmSerializer;
|
||||||
|
|
||||||
constructor(extensions, includeDefault = true) {
|
constructor(extensions, pluginParams, includeDefault = true) {
|
||||||
this.nodes = includeDefault ? { ...defaultMarkdownSerializer.nodes } : {};
|
this.nodes = includeDefault ? { ...defaultMarkdownSerializer.nodes } : {};
|
||||||
this.nodes.hard_break = (state) => state.write("\n");
|
this.nodes.hard_break = (state) => state.write("\n");
|
||||||
|
|
||||||
this.marks = includeDefault ? { ...defaultMarkdownSerializer.marks } : {};
|
this.marks = includeDefault ? { ...defaultMarkdownSerializer.marks } : {};
|
||||||
|
|
||||||
this.#extractNodeSerializers(extensions);
|
this.#extractNodeSerializers(extensions, pluginParams);
|
||||||
this.#extractMarkSerializers(extensions);
|
this.#extractMarkSerializers(extensions, pluginParams);
|
||||||
|
|
||||||
this.#pmSerializer = new MarkdownSerializer(this.nodes, this.marks);
|
this.#pmSerializer = new MarkdownSerializer(this.nodes, this.marks);
|
||||||
}
|
}
|
||||||
@ -22,15 +22,23 @@ export default class Serializer {
|
|||||||
return this.#pmSerializer.serialize(doc);
|
return this.#pmSerializer.serialize(doc);
|
||||||
}
|
}
|
||||||
|
|
||||||
#extractNodeSerializers(extensions) {
|
#extractNodeSerializers(extensions, pluginParams) {
|
||||||
for (const { serializeNode } of extensions) {
|
for (const { serializeNode } of extensions) {
|
||||||
Object.assign(this.nodes, serializeNode);
|
const serializer =
|
||||||
|
typeof serializeNode === "function"
|
||||||
|
? serializeNode(pluginParams)
|
||||||
|
: serializeNode;
|
||||||
|
Object.assign(this.nodes, serializer);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#extractMarkSerializers(extensions) {
|
#extractMarkSerializers(extensions, pluginParams) {
|
||||||
for (const { serializeMark } of extensions) {
|
for (const { serializeMark } of extensions) {
|
||||||
Object.assign(this.marks, serializeMark);
|
const serializer =
|
||||||
|
typeof serializeMark === "function"
|
||||||
|
? serializeMark(pluginParams)
|
||||||
|
: serializeMark;
|
||||||
|
Object.assign(this.marks, serializer);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -13,6 +13,7 @@ import { i18n } from "discourse-i18n";
|
|||||||
import generateDateMarkup from "discourse/plugins/discourse-local-dates/lib/local-date-markup-generator";
|
import generateDateMarkup from "discourse/plugins/discourse-local-dates/lib/local-date-markup-generator";
|
||||||
import LocalDatesCreateModal from "../discourse/components/modal/local-dates-create";
|
import LocalDatesCreateModal from "../discourse/components/modal/local-dates-create";
|
||||||
import LocalDateBuilder from "../lib/local-date-builder";
|
import LocalDateBuilder from "../lib/local-date-builder";
|
||||||
|
import richEditorExtension from "../lib/rich-editor-extension";
|
||||||
|
|
||||||
// Import applyLocalDates from discourse/lib/local-dates instead
|
// Import applyLocalDates from discourse/lib/local-dates instead
|
||||||
export function applyLocalDates(dates, siteSettings) {
|
export function applyLocalDates(dates, siteSettings) {
|
||||||
@ -142,6 +143,8 @@ function _partitionedRanges(element) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function initializeDiscourseLocalDates(api) {
|
function initializeDiscourseLocalDates(api) {
|
||||||
|
api.registerRichEditorExtension(richEditorExtension);
|
||||||
|
|
||||||
const modal = api.container.lookup("service:modal");
|
const modal = api.container.lookup("service:modal");
|
||||||
const siteSettings = api.container.lookup("service:site-settings");
|
const siteSettings = api.container.lookup("service:site-settings");
|
||||||
const defaultTitle = i18n("discourse_local_dates.default_title", {
|
const defaultTitle = i18n("discourse_local_dates.default_title", {
|
||||||
|
@ -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;
|
@ -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
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
Reference in New Issue
Block a user