From d1870d4811a931b24ca782dd8187f1106b935df8 Mon Sep 17 00:00:00 2001 From: Renato Atilio Date: Tue, 11 Mar 2025 20:13:09 -0300 Subject: [PATCH] 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. --- .../lib/composer/rich-editor-extensions.js | 8 +- .../components/prosemirror-editor.gjs | 6 +- .../app/static/prosemirror/core/serializer.js | 22 +- .../initializers/discourse-local-dates.js | 3 + .../javascripts/lib/rich-editor-extension.js | 195 ++++++++++++++++++ .../spec/system/rich_editor_extension_spec.rb | 58 ++++++ .../integration/rich-editor-extension-test.js | 38 ++++ 7 files changed, 319 insertions(+), 11 deletions(-) create mode 100644 plugins/discourse-local-dates/assets/javascripts/lib/rich-editor-extension.js create mode 100644 plugins/discourse-local-dates/spec/system/rich_editor_extension_spec.rb create mode 100644 plugins/discourse-local-dates/test/javascripts/integration/rich-editor-extension-test.js diff --git a/app/assets/javascripts/discourse/app/lib/composer/rich-editor-extensions.js b/app/assets/javascripts/discourse/app/lib/composer/rich-editor-extensions.js index 166f8562c80..1cad1658456 100644 --- a/app/assets/javascripts/discourse/app/lib/composer/rich-editor-extensions.js +++ b/app/assets/javascripts/discourse/app/lib/composer/rich-editor-extensions.js @@ -53,6 +53,9 @@ /** @typedef {((params: PluginParams) => KeymapSpec)} RichKeymapFn */ /** @typedef {KeymapSpec | RichKeymapFn} RichKeymap */ +// @ts-ignore MarkSerializerSpec not currently exported +/** @typedef {import('prosemirror-markdown').MarkSerializerSpec} MarkSerializerSpec */ + /** * @typedef {Object} RichEditorExtension * @property {Record} [nodeSpec] @@ -64,10 +67,9 @@ * @property {RichInputRule | Array} [inputRules] * ProseMirror input rules. See https://prosemirror.net/docs/ref/#inputrules.InputRule * can be a function returning an array or an array of input rules - * @property {Record} [serializeNode] + * @property {(params: PluginParams) => Record | Record} [serializeNode] * Node serialization definition - * @ts-ignore MarkSerializerSpec not currently exported - * @property {Record} [serializeMark] + * @property {(params: PluginParams) => Record | Record} [serializeMark] * Mark serialization definition * @property {Record} [parse] * Markdown-it token parse definition diff --git a/app/assets/javascripts/discourse/app/static/prosemirror/components/prosemirror-editor.gjs b/app/assets/javascripts/discourse/app/static/prosemirror/components/prosemirror-editor.gjs index d17e3ee739e..2ecd85345a8 100644 --- a/app/assets/javascripts/discourse/app/static/prosemirror/components/prosemirror-editor.gjs +++ b/app/assets/javascripts/discourse/app/static/prosemirror/components/prosemirror-editor.gjs @@ -144,7 +144,11 @@ export default class ProsemirrorEditor extends Component { ]; 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 }); diff --git a/app/assets/javascripts/discourse/app/static/prosemirror/core/serializer.js b/app/assets/javascripts/discourse/app/static/prosemirror/core/serializer.js index 224238fbbc3..4acd4b007cc 100644 --- a/app/assets/javascripts/discourse/app/static/prosemirror/core/serializer.js +++ b/app/assets/javascripts/discourse/app/static/prosemirror/core/serializer.js @@ -6,14 +6,14 @@ import { export default class Serializer { #pmSerializer; - constructor(extensions, includeDefault = true) { + constructor(extensions, pluginParams, includeDefault = true) { this.nodes = includeDefault ? { ...defaultMarkdownSerializer.nodes } : {}; this.nodes.hard_break = (state) => state.write("\n"); this.marks = includeDefault ? { ...defaultMarkdownSerializer.marks } : {}; - this.#extractNodeSerializers(extensions); - this.#extractMarkSerializers(extensions); + this.#extractNodeSerializers(extensions, pluginParams); + this.#extractMarkSerializers(extensions, pluginParams); this.#pmSerializer = new MarkdownSerializer(this.nodes, this.marks); } @@ -22,15 +22,23 @@ export default class Serializer { return this.#pmSerializer.serialize(doc); } - #extractNodeSerializers(extensions) { + #extractNodeSerializers(extensions, pluginParams) { 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) { - Object.assign(this.marks, serializeMark); + const serializer = + typeof serializeMark === "function" + ? serializeMark(pluginParams) + : serializeMark; + Object.assign(this.marks, serializer); } } } diff --git a/plugins/discourse-local-dates/assets/javascripts/initializers/discourse-local-dates.js b/plugins/discourse-local-dates/assets/javascripts/initializers/discourse-local-dates.js index ead4f5aec19..2bf8075e9ae 100644 --- a/plugins/discourse-local-dates/assets/javascripts/initializers/discourse-local-dates.js +++ b/plugins/discourse-local-dates/assets/javascripts/initializers/discourse-local-dates.js @@ -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", { diff --git a/plugins/discourse-local-dates/assets/javascripts/lib/rich-editor-extension.js b/plugins/discourse-local-dates/assets/javascripts/lib/rich-editor-extension.js new file mode 100644 index 00000000000..93a5bb732a6 --- /dev/null +++ b/plugins/discourse-local-dates/assets/javascripts/lib/rich-editor-extension.js @@ -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; diff --git a/plugins/discourse-local-dates/spec/system/rich_editor_extension_spec.rb b/plugins/discourse-local-dates/spec/system/rich_editor_extension_spec.rb new file mode 100644 index 00000000000..a13a6152836 --- /dev/null +++ b/plugins/discourse-local-dates/spec/system/rich_editor_extension_spec.rb @@ -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 diff --git a/plugins/discourse-local-dates/test/javascripts/integration/rich-editor-extension-test.js b/plugins/discourse-local-dates/test/javascripts/integration/rich-editor-extension-test.js new file mode 100644 index 00000000000..6b47a56ccf6 --- /dev/null +++ b/plugins/discourse-local-dates/test/javascripts/integration/rich-editor-extension-test.js @@ -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]", + '

2021-01-01 12:00:00

', + "[date=2021-01-01 time=12:00:00]", + ], + "local date with timezone": [ + '[date=2021-01-01 time=12:00:00 timezone="America/New_York"]', + '

2021-01-01 12:00:00

', + '[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]", + '

2021-01-012021-01-02

', + "[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"]', + '

2021-01-01 12:00:002021-01-02 13:00:00

', + '[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); + }); + }); + } +);