From 0e61565b2bb5aa24da060b701378b22dee056312 Mon Sep 17 00:00:00 2001 From: Renato Atilio Date: Tue, 4 Feb 2025 14:37:18 -0300 Subject: [PATCH] FEATURE: introduce a ProseMirror editor (#30815) This is the first in a series of PRs to introduce a ProseMirror-based WYSIWYM editor experience alongside our current textarea Markdown editor. Behind a hidden site setting, this PR adds a toggle to the composer toolbar, allowing users to switch between the two options. Our implementation builds upon the excellent ProseMirror and its non-core Markdown module, using the module's schema, parsing, and serialization definitions as the base for further Discourse-specific features. An extension API is included to enable further customizations. The necessary extensions to support all Discourse's core and core plugins features **will be implemented in subsequent PRs**. --------- Co-authored-by: David Taylor --- .../app/components/composer-container.hbs | 34 +- .../app/components/composer-editor.hbs | 3 +- .../app/components/composer-editor.js | 27 +- .../components/composer/textarea-editor.gjs | 2 +- .../app/components/composer/toggle-switch.gjs | 47 ++ .../discourse/app/components/d-editor.hbs | 13 +- .../discourse/app/components/d-editor.js | 48 +- .../discourse/app/lib/autocomplete.js | 61 ++- .../lib/composer/rich-editor-extensions.js | 107 ++++ .../app/lib/composer/text-manipulation.js | 305 +++++++++++ .../discourse/app/lib/intercept-click.js | 1 + .../discourse/app/lib/load-rich-editor.js | 5 + .../discourse/app/lib/plugin-api.gjs | 14 +- .../app/lib/textarea-text-manipulation.js | 44 +- .../discourse/app/lib/uppy/composer-upload.js | 18 +- .../discourse/app/services/composer.js | 49 +- .../discourse/app/static/markdown-it/index.js | 2 +- .../components/prosemirror-editor.gjs | 249 +++++++++ .../app/static/prosemirror/core/inputrules.js | 128 +++++ .../app/static/prosemirror/core/keymap.js | 77 +++ .../app/static/prosemirror/core/parser.js | 84 +++ .../app/static/prosemirror/core/plugin.js | 49 ++ .../app/static/prosemirror/core/schema.js | 41 ++ .../app/static/prosemirror/core/serializer.js | 36 ++ .../prosemirror/extensions/placeholder.js | 56 ++ .../extensions/register-default.js | 11 + .../app/static/prosemirror/lib/markdown-it.js | 27 + .../static/prosemirror/lib/plugin-utils.js | 1 + .../prosemirror/lib/text-manipulation.js | 490 ++++++++++++++++++ app/assets/javascripts/discourse/package.json | 16 +- .../tests/helpers/rich-editor-helper.gjs | 64 +++ .../prosemirror-editor-test.gjs | 203 ++++++++ .../prosemirror-markdown-test.gjs | 152 ++++++ app/assets/stylesheets/common.scss | 1 + .../stylesheets/common/components/_index.scss | 1 + .../components/composer-toggle-switch.scss | 93 ++++ .../common/rich-editor/_index.scss | 1 + .../common/rich-editor/rich-editor.scss | 256 +++++++++ app/assets/stylesheets/mobile/compose.scss | 1 + config/site_settings.yml | 4 + docs/CHANGELOG-JAVASCRIPT-PLUGIN-API.md | 4 + jsconfig.json | 1 + lib/svg_sprite.rb | 2 + .../discourse/components/chat-channel.gjs | 6 +- .../initializers/chat-keyboard-shortcuts.js | 10 +- pnpm-lock.yaml | 176 +++++++ script/build_jsconfig.rb | 1 + .../composer/prosemirror_editor_spec.rb | 290 +++++++++++ .../page_objects/components/composer.rb | 19 + 49 files changed, 3200 insertions(+), 130 deletions(-) create mode 100644 app/assets/javascripts/discourse/app/components/composer/toggle-switch.gjs create mode 100644 app/assets/javascripts/discourse/app/lib/composer/rich-editor-extensions.js create mode 100644 app/assets/javascripts/discourse/app/lib/composer/text-manipulation.js create mode 100644 app/assets/javascripts/discourse/app/lib/load-rich-editor.js create mode 100644 app/assets/javascripts/discourse/app/static/prosemirror/components/prosemirror-editor.gjs create mode 100644 app/assets/javascripts/discourse/app/static/prosemirror/core/inputrules.js create mode 100644 app/assets/javascripts/discourse/app/static/prosemirror/core/keymap.js create mode 100644 app/assets/javascripts/discourse/app/static/prosemirror/core/parser.js create mode 100644 app/assets/javascripts/discourse/app/static/prosemirror/core/plugin.js create mode 100644 app/assets/javascripts/discourse/app/static/prosemirror/core/schema.js create mode 100644 app/assets/javascripts/discourse/app/static/prosemirror/core/serializer.js create mode 100644 app/assets/javascripts/discourse/app/static/prosemirror/extensions/placeholder.js create mode 100644 app/assets/javascripts/discourse/app/static/prosemirror/extensions/register-default.js create mode 100644 app/assets/javascripts/discourse/app/static/prosemirror/lib/markdown-it.js create mode 100644 app/assets/javascripts/discourse/app/static/prosemirror/lib/plugin-utils.js create mode 100644 app/assets/javascripts/discourse/app/static/prosemirror/lib/text-manipulation.js create mode 100644 app/assets/javascripts/discourse/tests/helpers/rich-editor-helper.gjs create mode 100644 app/assets/javascripts/discourse/tests/integration/components/prosemirror-editor/prosemirror-editor-test.gjs create mode 100644 app/assets/javascripts/discourse/tests/integration/components/prosemirror-editor/prosemirror-markdown-test.gjs create mode 100644 app/assets/stylesheets/common/components/composer-toggle-switch.scss create mode 100644 app/assets/stylesheets/common/rich-editor/_index.scss create mode 100644 app/assets/stylesheets/common/rich-editor/rich-editor.scss create mode 100644 spec/system/composer/prosemirror_editor_spec.rb diff --git a/app/assets/javascripts/discourse/app/components/composer-container.hbs b/app/assets/javascripts/discourse/app/components/composer-container.hbs index ca0002889a1..8995737599f 100644 --- a/app/assets/javascripts/discourse/app/components/composer-container.hbs +++ b/app/assets/javascripts/discourse/app/components/composer-container.hbs @@ -1,6 +1,6 @@
{{#if this.composer.visible}} - {{html-class (if this.composer.showPreview "composer-has-preview")}} + {{html-class (if this.composer.isPreviewVisible "composer-has-preview")}} @@ -295,17 +295,19 @@ {{/if}} - - {{d-icon "desktop"}} - + {{#if this.composer.allowPreview}} + + {{d-icon "desktop"}} + + {{/if}} - {{#if this.composer.showPreview}} + {{#if this.composer.isPreviewVisible}} - {{#if this.site.desktopView}} + {{#if (and this.composer.allowPreview this.site.desktopView)}} {{/if}} diff --git a/app/assets/javascripts/discourse/app/components/composer-editor.hbs b/app/assets/javascripts/discourse/app/components/composer-editor.hbs index a3ee48a7e35..4ae5c64a46b 100644 --- a/app/assets/javascripts/discourse/app/components/composer-editor.hbs +++ b/app/assets/javascripts/discourse/app/components/composer-editor.hbs @@ -30,8 +30,7 @@ @previewUpdated={{action "previewUpdated"}} @markdownOptions={{this.markdownOptions}} @extraButtons={{action "extraButtons"}} - @importQuote={{this.composer.importQuote}} - @processPreview={{this.composer.showPreview}} + @processPreview={{this.composer.isPreviewVisible}} @validation={{this.validation}} @loading={{this.composer.loading}} @forcePreview={{this.forcePreview}} diff --git a/app/assets/javascripts/discourse/app/components/composer-editor.js b/app/assets/javascripts/discourse/app/components/composer-editor.js index 8e76b30d93e..d2887989cf3 100644 --- a/app/assets/javascripts/discourse/app/components/composer-editor.js +++ b/app/assets/javascripts/discourse/app/components/composer-editor.js @@ -194,17 +194,29 @@ export default class ComposerEditor extends Component { this.appEvents.trigger(`${this.composerEventPrefix}:will-open`); } + /** + * Sets up the editor with the given text manipulation instance + * + * @param {TextManipulation} textManipulation The text manipulation instance + * @returns {(() => void)} destructor function + */ @bind setupEditor(textManipulation) { this.textManipulation = textManipulation; - this.uppyComposerUpload.textManipulation = textManipulation; + this.uppyComposerUpload.placeholderHandler = textManipulation.placeholder; const input = this.element.querySelector(".d-editor-input"); input.addEventListener("scroll", this._throttledSyncEditorAndPreviewScroll); - // Focus on the body unless we have a title - if (!this.get("composer.model.canEditTitle")) { + this.composer.set("allowPreview", this.textManipulation.allowPreview); + + if ( + // Focus on the editor unless we have a title + !this.get("composer.model.canEditTitle") || + // Or focus is in the body (e.g. when the editor is destroyed) + document.activeElement.tagName === "BODY" + ) { this.textManipulation.putCursorAtEnd(); } @@ -859,15 +871,6 @@ export default class ComposerEditor extends Component { @action extraButtons(toolbar) { - toolbar.addButton({ - id: "quote", - group: "fontStyles", - icon: "far-comment", - sendAction: this.composer.importQuote, - title: "composer.quote_post_title", - unshift: true, - }); - if ( this.composer.allowUpload && this.composer.uploadIcon && diff --git a/app/assets/javascripts/discourse/app/components/composer/textarea-editor.gjs b/app/assets/javascripts/discourse/app/components/composer/textarea-editor.gjs index beadea93fab..b1c635c37da 100644 --- a/app/assets/javascripts/discourse/app/components/composer/textarea-editor.gjs +++ b/app/assets/javascripts/discourse/app/components/composer/textarea-editor.gjs @@ -110,7 +110,7 @@ export default class TextareaEditor extends Component { @input={{@change}} @focusIn={{@focusIn}} @focusOut={{@focusOut}} - class="d-editor-input" + class={{@class}} @id={{@id}} {{this.registerTextarea}} /> diff --git a/app/assets/javascripts/discourse/app/components/composer/toggle-switch.gjs b/app/assets/javascripts/discourse/app/components/composer/toggle-switch.gjs new file mode 100644 index 00000000000..bc60150123a --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/composer/toggle-switch.gjs @@ -0,0 +1,47 @@ +import Component from "@glimmer/component"; +import { on } from "@ember/modifier"; +import { action } from "@ember/object"; +import concatClass from "discourse/helpers/concat-class"; +import icon from "discourse/helpers/d-icon"; + +export default class ComposerToggleSwitch extends Component { + @action + mouseDown(event) { + if (this.args.preventFocus) { + event.preventDefault(); + } + } + + +} diff --git a/app/assets/javascripts/discourse/app/components/d-editor.hbs b/app/assets/javascripts/discourse/app/components/d-editor.hbs index 27a5f94ca07..8349bd32948 100644 --- a/app/assets/javascripts/discourse/app/components/d-editor.hbs +++ b/app/assets/javascripts/discourse/app/components/d-editor.hbs @@ -8,6 +8,14 @@ {{if this.isEditorFocused 'in-focus'}}" >