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 <david@taylorhq.com>
This commit is contained in:
Renato Atilio 2025-02-04 14:37:18 -03:00 committed by GitHub
parent 6d6e9c174d
commit 0e61565b2b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
49 changed files with 3200 additions and 130 deletions

View File

@ -1,6 +1,6 @@
<ComposerBody
@composer={{this.composer.model}}
@showPreview={{this.composer.showPreview}}
@showPreview={{this.composer.isPreviewVisible}}
@openIfDraft={{this.composer.openIfDraft}}
@typed={{this.composer.typed}}
@cancelled={{this.composer.cancelled}}
@ -8,7 +8,7 @@
>
<div class="grippie"></div>
{{#if this.composer.visible}}
{{html-class (if this.composer.showPreview "composer-has-preview")}}
{{html-class (if this.composer.isPreviewVisible "composer-has-preview")}}
<ComposerMessages
@composer={{this.composer.model}}
@messageCount={{this.composer.messageCount}}
@ -137,7 +137,7 @@
<div
class="title-and-category
{{if this.composer.showPreview 'with-preview'}}"
{{if this.composer.isPreviewVisible 'with-preview'}}"
>
<ComposerTitle
@composer={{this.composer.model}}
@ -205,7 +205,7 @@
@connectorTagName="div"
@outletArgs={{hash
model=this.composer.model
showPreview=this.composer.showPreview
showPreview=this.composer.isPreviewVisible
}}
/>
</span>
@ -295,17 +295,19 @@
</a>
{{/if}}
<a
href
class="btn btn-default no-text mobile-preview"
title={{i18n "composer.show_preview"}}
{{on "click" this.composer.togglePreview}}
aria-label={{i18n "composer.show_preview"}}
>
{{d-icon "desktop"}}
</a>
{{#if this.composer.allowPreview}}
<a
href
class="btn btn-default no-text mobile-preview"
title={{i18n "composer.show_preview"}}
{{on "click" this.composer.togglePreview}}
aria-label={{i18n "composer.show_preview"}}
>
{{d-icon "desktop"}}
</a>
{{/if}}
{{#if this.composer.showPreview}}
{{#if this.composer.isPreviewVisible}}
<DButton
@action={{this.composer.togglePreview}}
@title="composer.hide_preview"
@ -366,14 +368,14 @@
{{/if}}
</div>
{{#if this.site.desktopView}}
{{#if (and this.composer.allowPreview this.site.desktopView)}}
<DButton
@action={{this.composer.togglePreview}}
@translatedTitle={{this.composer.toggleText}}
@icon="angles-left"
class={{concat-class
"btn-transparent btn-mini-toggle toggle-preview"
(unless this.composer.showPreview "active")
(unless this.composer.isPreviewVisible "active")
}}
/>
{{/if}}

View File

@ -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}}

View File

@ -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 &&

View File

@ -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}}
/>

View File

@ -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();
}
}
<template>
{{! template-lint-disable no-redundant-role }}
<button
class={{concatClass
"composer-toggle-switch"
(if @state "--rte" "--markdown")
}}
type="button"
role="switch"
aria-checked={{if @state "true" "false"}}
{{! template-lint-disable no-pointer-down-event-binding }}
{{on "mousedown" this.mouseDown}}
...attributes
>
<span class="composer-toggle-switch__slider">
<span
class={{concatClass
"composer-toggle-switch__left-icon"
(unless @state "--active")
}}
aria-hidden="true"
>{{icon "fab-markdown"}}</span>
<span
class={{concatClass
"composer-toggle-switch__right-icon"
(if @state "--active")
}}
aria-hidden="true"
>{{icon "a"}}</span>
</span>
</button>
</template>
}

View File

@ -8,6 +8,14 @@
{{if this.isEditorFocused 'in-focus'}}"
>
<div class="d-editor-button-bar" role="toolbar">
{{#if this.siteSettings.rich_editor}}
<Composer::ToggleSwitch
@preventFocus={{true}}
@state={{this.isRichEditorEnabled}}
{{on "click" this.toggleRichEditor}}
/>
{{/if}}
{{#each this.toolbar.groups as |group|}}
{{#each group.buttons as |b|}}
{{#if (b.condition this)}}
@ -40,15 +48,18 @@
<ConditionalLoadingSpinner @condition={{this.loading}} />
<this.editorComponent
@class="d-editor-input"
@onSetup={{this.setupEditor}}
@markdownOptions={{this.markdownOptions}}
@keymap={{this.keymap}}
@value={{this.value}}
@placeholder={{this.placeholderTranslated}}
@disabled={{this.disabled}}
@change={{this.change}}
@change={{this.onChange}}
@focusIn={{this.handleFocusIn}}
@focusOut={{this.handleFocusOut}}
@categoryId={{@categoryId}}
@topicId={{@topicId}}
@id={{this.textAreaId}}
/>
<PopupInputTip @validation={{this.validation}} />

View File

@ -1,3 +1,4 @@
import { tracked } from "@glimmer/tracking";
import Component from "@ember/component";
import { action } from "@ember/object";
import { getOwner } from "@ember/owner";
@ -26,6 +27,7 @@ import { wantsNewWindow } from "discourse/lib/intercept-click";
import { PLATFORM_KEY_MODIFIER } from "discourse/lib/keyboard-shortcuts";
import { linkSeenMentions } from "discourse/lib/link-mentions";
import { loadOneboxes } from "discourse/lib/load-oneboxes";
import loadRichEditor from "discourse/lib/load-rich-editor";
import { findRawTemplate } from "discourse/lib/raw-templates";
import { emojiUrlFor, generateCookFunction } from "discourse/lib/text";
import userSearch from "discourse/lib/user-search";
@ -59,8 +61,9 @@ export default class DEditor extends Component {
@service modal;
@service menu;
editorComponent = TextareaEditor;
textManipulation;
@tracked editorComponent;
/** @type {TextManipulation} */
@tracked textManipulation;
ready = false;
lastSel = null;
@ -74,10 +77,19 @@ export default class DEditor extends Component {
},
};
init() {
async init() {
super.init(...arguments);
this.register = getRegister(this);
if (
this.siteSettings.rich_editor &&
this.keyValueStore.get("d-editor-prefers-rich-editor") === "true"
) {
this.editorComponent = await loadRichEditor();
} else {
this.editorComponent = TextareaEditor;
}
}
@discourseComputed("placeholder")
@ -630,9 +642,15 @@ export default class DEditor extends Component {
this.set("isEditorFocused", false);
}
/**
* Sets up the editor with the given text manipulation instance
*
* @param {TextManipulation} textManipulation The text manipulation instance
* @returns {(() => void)} destructor function
*/
@action
setupEditor(textManipulation) {
this.set("textManipulation", textManipulation);
this.textManipulation = textManipulation;
const destroyEvents = this.setupEvents();
@ -657,6 +675,28 @@ export default class DEditor extends Component {
};
}
@action
async toggleRichEditor() {
this.editorComponent = this.isRichEditorEnabled
? TextareaEditor
: await loadRichEditor();
this.keyValueStore.set({
key: "d-editor-prefers-rich-editor",
value: this.isRichEditorEnabled,
});
}
@action
onChange(event) {
this.set("value", event?.target?.value);
this.change?.(event);
}
get isRichEditorEnabled() {
return this.editorComponent !== TextareaEditor;
}
setupEvents() {
const textManipulation = this.textManipulation;

View File

@ -114,6 +114,7 @@ export default function (options) {
const isInput = me[0].tagName === "INPUT" && !options.treatAsTextarea;
let inputSelectedItems = [];
/** @type {AutocompleteHandler} */
options.textHandler ??= new TextareaAutocompleteHandler(me[0]);
function handlePaste() {
@ -249,14 +250,14 @@ export default function (options) {
options.textHandler.getCaretPosition();
}
options.textHandler.replaceTerm({
start: completeStart,
end: completeEnd,
term: (options.preserveKey ? options.key || "" : "") + term,
});
options.textHandler.replaceTerm(
completeStart,
completeEnd,
(options.preserveKey ? options.key || "" : "") + term
);
if (options && options.afterComplete) {
options.afterComplete(options.textHandler.value, event);
options.afterComplete(options.textHandler.getValue(), event);
}
}
}
@ -481,7 +482,9 @@ export default function (options) {
if (
(term.length !== 0 && term.trim().length === 0) ||
// close unless the caret is at the end of a word, like #line|<-
options.textHandler.value[options.textHandler.getCaretPosition()]?.trim()
options.textHandler
.getValue()
[options.textHandler.getCaretPosition()]?.trim()
) {
closeAutocomplete();
return null;
@ -549,11 +552,11 @@ export default function (options) {
}
let cp = options.textHandler.getCaretPosition();
const key = options.textHandler.value[cp - 1];
const key = options.textHandler.getValue()[cp - 1];
if (options.key) {
if (options.onKeyUp && key !== options.key) {
let match = options.onKeyUp(options.textHandler.value, cp);
let match = options.onKeyUp(options.textHandler.getValue(), cp);
if (match && (await checkTriggerRule())) {
completeStart = cp - match[0].length;
@ -565,7 +568,7 @@ export default function (options) {
if (completeStart === null && cp > 0) {
if (key === options.key) {
let prevChar = options.textHandler.value.charAt(cp - 2);
let prevChar = options.textHandler.getValue().charAt(cp - 2);
if (
(!prevChar || ALLOWED_LETTERS_REGEXP.test(prevChar)) &&
(await checkTriggerRule())
@ -575,11 +578,17 @@ export default function (options) {
}
}
} else if (completeStart !== null) {
let term = options.textHandler.value.substring(
completeStart + (options.key ? 1 : 0),
cp
);
updateAutoComplete(dataSource(term, options));
let term = options.textHandler
.getValue()
.substring(completeStart + (options.key ? 1 : 0), cp);
if (
!options.key ||
options.textHandler.getValue()[completeStart] === options.key
) {
updateAutoComplete(dataSource(term, options));
} else {
closeAutocomplete();
}
}
}
@ -601,12 +610,12 @@ export default function (options) {
while (prevIsGood && caretPos >= 0) {
caretPos -= 1;
prev = options.textHandler.value[caretPos];
prev = options.textHandler.getValue()[caretPos];
stopFound = prev === options.key;
if (stopFound) {
prev = options.textHandler.value[caretPos - 1];
prev = options.textHandler.getValue()[caretPos - 1];
const shouldTrigger = await checkTriggerRule({ backSpace });
if (
@ -614,10 +623,9 @@ export default function (options) {
(prev === undefined || ALLOWED_LETTERS_REGEXP.test(prev))
) {
start = caretPos;
term = options.textHandler.value.substring(
caretPos + 1,
initialCaretPos
);
term = options.textHandler
.getValue()
.substring(caretPos + 1, initialCaretPos);
end = caretPos + term.length;
break;
}
@ -648,7 +656,7 @@ export default function (options) {
inputSelectedItems.push("");
}
const value = options.textHandler.value;
const value = options.textHandler.getValue();
if (typeof inputSelectedItems[0] === "string" && value.length > 0) {
inputSelectedItems.pop();
inputSelectedItems.push(value);
@ -694,7 +702,7 @@ export default function (options) {
// allow people to right arrow out of completion
if (
e.which === keys.rightArrow &&
options.textHandler.value[cp] === " "
options.textHandler.getValue()[cp] === " "
) {
closeAutocomplete();
return true;
@ -770,10 +778,9 @@ export default function (options) {
return true;
}
term = options.textHandler.value.substring(
completeStart + (options.key ? 1 : 0),
cp
);
term = options.textHandler
.getValue()
.substring(completeStart + (options.key ? 1 : 0), cp);
if (completeStart === cp && term === options.key) {
closeAutocomplete();

View File

@ -0,0 +1,107 @@
// @ts-check
/**
* @typedef PluginContext
* @property {string} placeholder
* @property {number} topicId
* @property {number} categoryId
* @property {import("discourse/models/session").default} session
*/
/**
* @typedef PluginParams
* @property {typeof import("discourse/static/prosemirror/lib/plugin-utils")} utils
* @property {typeof import('prosemirror-model')} pmModel
* @property {typeof import('prosemirror-view')} pmView
* @property {typeof import('prosemirror-state')} pmState
* @property {typeof import('prosemirror-history')} pmHistory
* @property {typeof import('prosemirror-transform')} pmTransform
* @property {typeof import('prosemirror-commands')} pmCommands
* @property {import('prosemirror-model').Schema} schema
* @property {() => PluginContext} getContext
*/
/** @typedef {import('prosemirror-state').PluginSpec} PluginSpec */
/** @typedef {((params: PluginParams) => PluginSpec)} RichPluginFn */
/** @typedef {PluginSpec | RichPluginFn} RichPlugin */
/**
* @typedef InputRuleObject
* @property {RegExp} match
* @property {string | ((state: import('prosemirror-state').EditorState, match: RegExpMatchArray, start: number, end: number) => import('prosemirror-state').Transaction | null)} handler
* @property {{ undoable?: boolean, inCode?: boolean | "only" }} [options]
*/
/**
* @typedef InputRuleParams
* @property {import('prosemirror-model').Schema} schema
* @property {Function} markInputRule
*/
/** @typedef {((params: InputRuleParams) => InputRuleObject) | InputRuleObject} RichInputRule */
// @ts-ignore we don't have type definitions for markdown-it
/** @typedef {import("markdown-it").Token} MarkdownItToken */
/** @typedef {(state: unknown, token: MarkdownItToken, tokenStream: MarkdownItToken[], index: number) => boolean | void} ParseFunction */
/** @typedef {import("prosemirror-markdown").ParseSpec | ParseFunction} RichParseSpec */
/**
* @typedef {(state: import("prosemirror-markdown").MarkdownSerializerState, node: import("prosemirror-model").Node, parent: import("prosemirror-model").Node, index: number) => void} SerializeNodeFn
*/
/** @typedef {Record<string, import('prosemirror-state').Command>} KeymapSpec */
/** @typedef {((params: PluginParams) => KeymapSpec)} RichKeymapFn */
/** @typedef {KeymapSpec | RichKeymapFn} RichKeymap */
/**
* @typedef {Object} RichEditorExtension
* @property {Record<string, import('prosemirror-model').NodeSpec>} [nodeSpec]
* Map containing Prosemirror node spec definitions, each key being the node name
* See https://prosemirror.net/docs/ref/#model.NodeSpec
* @property {Record<string, import('prosemirror-model').MarkSpec>} [markSpec]
* Map containing Prosemirror mark spec definitions, each key being the mark name
* See https://prosemirror.net/docs/ref/#model.MarkSpec
* @property {RichInputRule | Array<RichInputRule>} [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<string, SerializeNodeFn>} [serializeNode]
* Node serialization definition
* @ts-ignore MarkSerializerSpec not currently exported
* @property {Record<string, import('prosemirror-markdown').MarkSerializerSpec>} [serializeMark]
* Mark serialization definition
* @property {Record<string, RichParseSpec>} [parse]
* Markdown-it token parse definition
* @property {RichPlugin | Array<RichPlugin>} [plugins]
* ProseMirror plugins
* @property {Record<string, import('prosemirror-view').NodeViewConstructor>} [nodeViews]
* ProseMirror node views
* @property {RichKeymap} [keymap]
* Additional keymap definitions
*/
/** @type {RichEditorExtension[]} */
const registeredExtensions = [];
/**
* Registers an extension for the rich editor
*
* EXPERIMENTAL: This API will change without warning
*
* @param {RichEditorExtension} extension
*/
export function registerRichEditorExtension(extension) {
registeredExtensions.push(extension);
}
export function resetRichEditorExtensions() {
registeredExtensions.length = 0;
}
/**
* Get all extensions registered for the rich editor
*
* @returns {RichEditorExtension[]}
*/
export function getExtensions() {
return registeredExtensions;
}

View File

@ -0,0 +1,305 @@
// @ts-check
/**
* Interface for text manipulation with an underlying editor implementation.
*
* @interface TextManipulation
*/
export const TextManipulation = {};
/**
* Whether the editor allows a preview being shown
* @name TextManipulation#allowPreview
* @type {boolean}
* @readonly
*/
/**
* Focuses the editor
*
* @method
* @name TextManipulation#focus
* @returns {void}
*/
/**
* Blurs and focuses the editor
*
* @method
* @name TextManipulation#blurAndFocus
* @returns {void}
*/
/**
* Indents/un-indents the current selection
*
* @method
* @name TextManipulation#indentSelection
* @param {string} direction The direction to indent in. Either "right" or "left"
* @returns {void}
*/
/**
* Configures an Autocomplete for the editor
*
* @method
* @name TextManipulation#autocomplete
* @param {unknown} options The options for the jQuery autocomplete
* @returns {void}
*/
/**
* Checks if the current selection is in a code block
*
* @method
* @name TextManipulation#inCodeBlock
* @returns {Promise<boolean>}
*/
/**
* Gets the current selection
*
* @method
* @name TextManipulation#getSelected
* @param {unknown} trimLeading
* @returns {unknown}
*/
/**
* Selects the text from the given range
*
* @method
* @name TextManipulation#selectText
* @param {number} from
* @param {number} to
* @param {unknown} [options]
* @returns {void}
*/
/**
* Applies the given head/tail to the selected text
*
* @method
* @name TextManipulation#applySurround
* @param {string} selected The selected text
* @param {string} head The text to be inserted before the selection
* @param {string} tail The text to be inserted after the selection
* @param {string} exampleKey The key of the example
* @param {unknown} [opts]
*/
/**
* Applies the list format to the selected text
*
* @method
* @name TextManipulation#applyList
* @param {string} selected The selected text
* @param {string} head The text to be inserted before the selection
* @param {string} exampleKey The key of the example
* @param {unknown} [opts]
*/
/**
* Formats the current selection as code
*
* @method
* @name TextManipulation#formatCode
* @returns {void}
*/
/**
* Adds text
*
* @method
* @name TextManipulation#addText
* @param {string} selected The selected text
* @param {string} text The text to be inserted
*/
/**
* Toggles the text (LTR/RTL) direction
*
* @method
* @name TextManipulation#toggleDirection
* @returns {void}
*/
/**
* Replaces text
*
* @method
* @name TextManipulation#replaceText
* @param {string} oldValue The old value
* @param {string} newValue The new value
* @param {unknown} [opts]
* @returns {void}
*/
/**
* Handles the paste event
*
* @method
* @name TextManipulation#paste
* @param {ClipboardEvent} event The paste event
* @returns {void}
*/
/**
* Inserts the block
*
* @method
* @name TextManipulation#insertBlock
* @param {string} block The block to be inserted
* @returns {void}
*/
/**
* Inserts text
*
* @method
* @name TextManipulation#insertText
* @param {string} text The text to be inserted
* @returns {void}
*/
/**
* Applies the head/tail to the selected text
*
* @method
* @name TextManipulation#applySurroundSelection
* @param {string} head The text to be inserted before the selection
* @param {string} tail The text to be inserted after the selection
* @param {string} exampleKey The key of the example
* @param {unknown} [opts]
* @returns {void}
*/
/**
* Puts cursor at the end of the editor
*
* @method
* @name TextManipulation#putCursorAtEnd
* @returns {void}
*/
/**
* The placeholder handler instance
*
* @name TextManipulation#placeholder
* @type {PlaceholderHandler}
* @readonly
*/
/** @typedef {import("@uppy/utils/lib/UppyFile").MinimalRequiredUppyFile<any,any>} UppyFile */
/**
* Interface for handling placeholders on upload events
*
* @interface PlaceholderHandler
*/
export const PlaceholderHandler = {};
/**
* Inserts a file
*
* @method
* @name PlaceholderHandler#insert
* @param {UppyFile} file The uploaded file
* @returns {void}
*/
/**
* Success event for file upload
*
* @method
* @name PlaceholderHandler#success
* @param {UppyFile} file The uploaded file
* @param {string} markdown The markdown for the uploaded file
* @returns {void}
*/
/**
* Cancels all uploads
*
* @method
* @name PlaceholderHandler#cancelAll
* @returns {void}
*/
/**
* Cancels one uploaded file
*
* @method
* @name PlaceholderHandler#cancel
* @param {UppyFile} file The uploaded file
* @returns {void}
*/
/**
* Progress event
*
* @method
* @name PlaceholderHandler#progress
* @param {UppyFile} file The uploaded file
* @returns {void}
*/
/**
* Progress complete event
*
* @method
* @name PlaceholderHandler#progressComplete
* @param {UppyFile} file The uploaded file
* @returns {void}
*/
/**
* Interface for the Autocomplete handler
*
* @interface AutocompleteHandler
*/
export const AutocompleteHandler = {};
/**
* Replaces the range with the given text
*
* @method
* @name AutocompleteHandler#replaceTerm
* @param {number} start The start of the range
* @param {number} end The end of the range
* @param {string} text The text to be inserted
* @returns {void}
*/
/**
* Gets the caret position
*
* @method
* @name AutocompleteHandler#getCaretPosition
* @returns {number}
*/
/**
* Checks if the current selection is in a code block
*
* @method
* @name AutocompleteHandler#inCodeBlock
* @returns {Promise<boolean>}
*/
/**
* Gets the caret coordinates
*
* @method
* @name AutocompleteHandler#getCaretCoords
* @param {number} caretPositon The caret position to get the coords for
* @returns {{ top: number, left: number }}
*/
/**
* Gets the current value for the autocomplete
*
* @method
* @name AutocompleteHandler#getValue
* @returns {string}
*/

View File

@ -48,6 +48,7 @@ export default function interceptClick(e) {
target.classList.contains("ember-view")) ||
target.classList.contains("lightbox") ||
href.startsWith("mailto:") ||
target.closest('[contenteditable="true"]') ||
(href.match(/^http[s]?:\/\//i) &&
!href.match(new RegExp("^https?:\\/\\/" + window.location.hostname, "i")))
) {

View File

@ -0,0 +1,5 @@
export default async function loadRichEditor() {
return (
await import("discourse/static/prosemirror/components/prosemirror-editor")
).default;
}

View File

@ -3,7 +3,7 @@
// docs/CHANGELOG-JAVASCRIPT-PLUGIN-API.md whenever you change the version
// using the format described at https://keepachangelog.com/en/1.0.0/.
export const PLUGIN_API_VERSION = "2.0.1";
export const PLUGIN_API_VERSION = "2.1.0";
import $ from "jquery";
import { h } from "virtual-dom";
@ -66,6 +66,7 @@ import classPrepend, {
withPrependsRolledBack,
} from "discourse/lib/class-prepend";
import { addPopupMenuOption } from "discourse/lib/composer/custom-popup-menu-options";
import { registerRichEditorExtension } from "discourse/lib/composer/rich-editor-extensions";
import deprecated from "discourse/lib/deprecated";
import { registerDesktopNotificationHandler } from "discourse/lib/desktop-notifications";
import { downloadCalendar } from "discourse/lib/download-calendar";
@ -3389,6 +3390,17 @@ class PluginApi {
registerReportModeComponent(mode, componentClass);
}
/**
* Registers an extension for the rich editor
*
* EXPERIMENTAL: This API will change without warning
*
* @param {RichEditorExtension} extension
*/
registerRichEditorExtension(extension) {
registerRichEditorExtension(extension);
}
#deprecatedWidgetOverride(widgetName, override) {
// insert here the code to handle widget deprecations, e.g. for the header widgets we used:
// if (DEPRECATED_HEADER_WIDGETS.includes(widgetName)) {

View File

@ -1,5 +1,6 @@
// @ts-check
import { setOwner } from "@ember/owner";
import { schedule } from "@ember/runloop";
import { next, schedule } from "@ember/runloop";
import { service } from "@ember/service";
import { isEmpty } from "@ember/utils";
import $ from "jquery";
@ -19,6 +20,12 @@ import {
} from "discourse/lib/utilities";
import { i18n } from "discourse-i18n";
/**
* @typedef {import("discourse/lib/composer/text-manipulation").TextManipulation} TextManipulation
* @typedef {import("discourse/lib/composer/text-manipulation").AutocompleteHandler} AutocompleteHandler
* @typedef {import("discourse/lib/composer/text-manipulation").PlaceholderHandler} PlaceholderHandler
*/
const INDENT_DIRECTION_LEFT = "left";
const INDENT_DIRECTION_RIGHT = "right";
@ -33,9 +40,13 @@ const OP = {
const FOUR_SPACES_INDENT = "4-spaces-indent";
// Our head can be a static string or a function that returns a string
// based on input (like for numbered lists).
export function getHead(head, prev) {
/**
* Our head can be a static string or a function that returns a string
* based on input (like for numbered lists).
*
* @returns {[string, number]}
*/
function getHead(head, prev) {
if (typeof head === "string") {
return [head, head.length];
} else {
@ -43,12 +54,15 @@ export function getHead(head, prev) {
}
}
/** @implements {TextManipulation} */
export default class TextareaTextManipulation {
@service appEvents;
@service siteSettings;
@service capabilities;
@service currentUser;
allowPreview = true;
eventPrefix;
textarea;
$textarea;
@ -816,11 +830,18 @@ export default class TextareaTextManipulation {
}
putCursorAtEnd() {
putCursorAtEnd(this.textarea);
if (this.capabilities.isIOS) {
putCursorAtEnd(this.textarea);
} else {
// in some browsers, the focus() called by putCursorAtEnd doesn't bubble the event to set
// isEditorFoused=true and bring the focus indicator to the wrapper, unless we do it on next tick
next(() => putCursorAtEnd(this.textarea));
}
}
autocomplete(options) {
return this.$textarea.autocomplete(
// @ts-ignore
this.$textarea.autocomplete(
options instanceof Object
? { textHandler: this.autocompleteHandler, ...options }
: options
@ -838,6 +859,7 @@ function insertAtTextarea(textarea, start, end, text) {
}
}
/** @implements {AutocompleteHandler} */
export class TextareaAutocompleteHandler {
textarea;
$textarea;
@ -847,12 +869,13 @@ export class TextareaAutocompleteHandler {
this.$textarea = $(textarea);
}
get value() {
getValue() {
return this.textarea.value;
}
replaceTerm({ start, end, term }) {
const space = this.value.substring(end + 1, end + 2) === " " ? "" : " ";
replaceTerm(start, end, term) {
const space =
this.getValue().substring(end + 1, end + 2) === " " ? "" : " ";
insertAtTextarea(this.textarea, start, end + 1, term + space);
setCaretPosition(this.textarea, start + 1 + term.trim().length);
}
@ -862,6 +885,7 @@ export class TextareaAutocompleteHandler {
}
getCaretCoords(start) {
// @ts-ignore
return this.$textarea.caretPosition({ pos: start + 1 });
}
@ -873,9 +897,11 @@ export class TextareaAutocompleteHandler {
}
}
/** @implements {PlaceholderHandler} */
class TextareaPlaceholderHandler {
@service composer;
/** @type {TextareaTextManipulation} */
textManipulation;
#placeholders = {};

View File

@ -52,7 +52,8 @@ export default class UppyComposerUpload {
uploadPreProcessors;
uploadHandlers;
textManipulation;
/** @type {PlaceholderHandler} */
placeholderHandler;
#inProgressUploads = [];
#bufferedUploadErrors = [];
@ -334,7 +335,7 @@ export default class UppyComposerUpload {
})
);
this.textManipulation.placeholder.insert(file);
this.placeholderHandler.insert(file);
this.appEvents.trigger(
`${this.composerEventPrefix}:upload-started`,
@ -369,7 +370,7 @@ export default class UppyComposerUpload {
file,
upload.url,
() => {
this.textManipulation.placeholder.success(file, markdown);
this.placeholderHandler.success(file, markdown);
this.appEvents.trigger(
`${this.composerEventPrefix}:upload-success`,
@ -395,7 +396,7 @@ export default class UppyComposerUpload {
this.uppyWrapper.uppyInstance.on("cancel-all", () => {
// Do the manual cancelling work only if the user clicked cancel
if (this.#userCancelled) {
this.textManipulation.placeholder.cancelAll();
this.placeholderHandler.cancelAll();
this.#userCancelled = false;
this.#reset();
@ -480,13 +481,13 @@ export default class UppyComposerUpload {
});
this.uppyWrapper.onPreProcessProgress((file) => {
this.textManipulation.placeholder.progress(file);
this.placeholderHandler.progress(file);
});
this.uppyWrapper.onPreProcessComplete(
(file) => {
run(() => {
this.textManipulation.placeholder.progressComplete(file);
this.placeholderHandler.progressComplete(file);
});
},
() => {
@ -529,13 +530,13 @@ export default class UppyComposerUpload {
}
#resetUpload(file) {
this.textManipulation.placeholder.cancel(file);
this.placeholderHandler.cancel(file);
}
@bind
_pasteEventListener(event) {
if (
document.activeElement !== document.querySelector(this.editorInputClass)
!document.querySelector(this.editorInputClass)?.contains(event.target)
) {
return;
}
@ -550,6 +551,7 @@ export default class UppyComposerUpload {
}
if (event && event.clipboardData && event.clipboardData.files) {
event.preventDefault();
this._addFiles([...event.clipboardData.files], { pasted: true });
}
}

View File

@ -1,3 +1,4 @@
import { tracked } from "@glimmer/tracking";
import EmberObject, { action, computed } from "@ember/object";
import { alias, and, or, reads } from "@ember/object/computed";
import { cancel, scheduleOnce } from "@ember/runloop";
@ -27,7 +28,6 @@ import { getOwnerWithFallback } from "discourse/lib/get-owner";
import getURL from "discourse/lib/get-url";
import { disableImplicitInjections } from "discourse/lib/implicit-injections";
import { wantsNewWindow } from "discourse/lib/intercept-click";
import { buildQuote } from "discourse/lib/quote";
import { emojiUnescape } from "discourse/lib/text";
import {
authorizesOneOrMoreExtensions,
@ -106,6 +106,8 @@ export default class ComposerService extends Service {
@service siteSettings;
@service store;
@tracked showPreview = true;
@tracked allowPreview = true;
checkedMessages = false;
messageCount = null;
showEditReason = false;
@ -119,7 +121,7 @@ export default class ComposerService extends Service {
uploadProgress;
topic = null;
linkLookup = null;
showPreview = true;
composerHeight = null;
@and("site.mobileView", "showPreview") forcePreview;
@ -135,6 +137,10 @@ export default class ComposerService extends Service {
return getOwnerWithFallback(this).lookup("controller:topic");
}
get isPreviewVisible() {
return this.showPreview && this.allowPreview;
}
get isOpen() {
return this.model?.composeState === Composer.OPEN;
}
@ -841,45 +847,6 @@ export default class ComposerService extends Service {
return false;
}
// Import a quote from the post
@action
async importQuote(toolbarEvent) {
const postStream = this.get("topic.postStream");
let postId = this.get("model.post.id");
// If there is no current post, use the first post id from the stream
if (!postId && postStream) {
postId = postStream.get("stream.firstObject");
}
// If we're editing a post, fetch the reply when importing a quote
if (this.get("model.editingPost")) {
const replyToPostNumber = this.get("model.post.reply_to_post_number");
if (replyToPostNumber) {
const replyPost = postStream.posts.findBy(
"post_number",
replyToPostNumber
);
if (replyPost) {
postId = replyPost.id;
}
}
}
if (!postId) {
return;
}
this.set("model.loading", true);
const post = await this.store.find("post", postId);
const quote = buildQuote(post, post.raw, { full: true });
toolbarEvent.addText(quote);
this.set("model.loading", false);
}
@action
saveAction(ignore, event) {
this.save(false, {

View File

@ -4,7 +4,7 @@ import loadPluginFeatures from "./features";
import MentionsParser from "./mentions-parser";
import buildOptions from "./options";
function buildEngine(options) {
export function buildEngine(options) {
return DiscourseMarkdownIt.withCustomFeatures(
loadPluginFeatures()
).withOptions(buildOptions(options));

View File

@ -0,0 +1,249 @@
// @ts-check
import Component from "@glimmer/component";
import { action } from "@ember/object";
import { getOwner } from "@ember/owner";
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import didUpdate from "@ember/render-modifiers/modifiers/did-update";
import willDestroy from "@ember/render-modifiers/modifiers/will-destroy";
import { next } from "@ember/runloop";
import { service } from "@ember/service";
import "../extensions/register-default";
import { baseKeymap } from "prosemirror-commands";
import * as ProsemirrorCommands from "prosemirror-commands";
import { dropCursor } from "prosemirror-dropcursor";
import { gapCursor } from "prosemirror-gapcursor";
import * as ProsemirrorHistory from "prosemirror-history";
import { history } from "prosemirror-history";
import { keymap } from "prosemirror-keymap";
import * as ProsemirrorModel from "prosemirror-model";
import * as ProsemirrorState from "prosemirror-state";
import { EditorState } from "prosemirror-state";
import * as ProsemirrorTransform from "prosemirror-transform";
import * as ProsemirrorView from "prosemirror-view";
import { EditorView } from "prosemirror-view";
import { getExtensions } from "discourse/lib/composer/rich-editor-extensions";
import { bind } from "discourse/lib/decorators";
import { buildInputRules } from "../core/inputrules";
import { buildKeymap } from "../core/keymap";
import Parser from "../core/parser";
import { extractNodeViews, extractPlugins } from "../core/plugin";
import { createSchema } from "../core/schema";
import Serializer from "../core/serializer";
import placeholder from "../extensions/placeholder";
import * as utils from "../lib/plugin-utils";
import TextManipulation from "../lib/text-manipulation";
const AUTOCOMPLETE_KEY_DOWN_SUPPRESS = ["Enter", "Tab"];
/**
* @typedef ProsemirrorEditorArgs
* @property {string} [value] The markdown content to be rendered in the editor
* @property {string} [placeholder] The placeholder text to be displayed when the editor is empty
* @property {boolean} [disabled] Whether the editor should be disabled
* @property {Record<string, () => void>} [keymap] A mapping of keybindings to commands
* @property {(value: { target: { value: string } }) => void} [change] A callback called when the editor content changes
* @property {() => void} [focusIn] A callback called when the editor gains focus
* @property {() => void} [focusOut] A callback called when the editor loses focus
* @property {(textManipulation: TextManipulation) => undefined | (() => void)} [onSetup] A callback called when the editor is set up, may return a destructor
* @property {number} [topicId] The ID of the topic being edited, if any
* @property {number} [categoryId] The ID of the category of the topic being edited, if any
* @property {string} [class] The class to be added to the ProseMirror contentEditable editor
* @property {boolean} [includeDefault] If default node and mark spec/parse/serialize/inputRules definitions from ProseMirror should be included
* @property {import("discourse/lib/composer/rich-editor-extensions").RichEditorExtension[]} [extensions] A list of extensions to be used with the editor INSTEAD of the ones registered through the API
*/
/**
* @typedef ProsemirrorEditorSignature
* @property {ProsemirrorEditorArgs} Args
*/
/**
* @extends {Component<ProsemirrorEditorSignature>}
*/
export default class ProsemirrorEditor extends Component {
@service session;
@service dialog;
schema = createSchema(this.extensions, this.args.includeDefault);
view;
#lastSerialized;
/** @type {undefined | (() => void)} */
#destructor;
get pluginParams() {
return {
utils,
schema: this.schema,
pmState: ProsemirrorState,
pmModel: ProsemirrorModel,
pmView: ProsemirrorView,
pmHistory: ProsemirrorHistory,
pmTransform: ProsemirrorTransform,
pmCommands: ProsemirrorCommands,
getContext: () => ({
placeholder: this.args.placeholder,
topicId: this.args.topicId,
categoryId: this.args.categoryId,
session: this.session,
}),
};
}
get extensions() {
const extensions = this.args.extensions ?? getExtensions();
// enforcing core extensions
return extensions.includes(placeholder)
? extensions
: [placeholder, ...extensions];
}
get keymapFromArgs() {
const replacements = { tab: "Tab" };
const result = {};
for (const [key, value] of Object.entries(this.args.keymap ?? {})) {
const pmKey = key
.split("+")
.map((word) => replacements[word] ?? word)
.join("-");
result[pmKey] = value;
}
return result;
}
@action
handleAsyncPlugin(plugin) {
const state = this.view.state.reconfigure({
plugins: [...this.view.state.plugins, plugin],
});
this.view.updateState(state);
}
@action
setup(container) {
const params = this.pluginParams;
const plugins = [
buildInputRules(this.extensions, this.schema, this.args.includeDefault),
keymap(
buildKeymap(
this.extensions,
this.schema,
this.keymapFromArgs,
params,
this.args.includeDefault
)
),
keymap(baseKeymap),
dropCursor({ color: "var(--primary)" }),
gapCursor(),
history(),
...extractPlugins(this.extensions, params, this.handleAsyncPlugin),
];
this.parser = new Parser(this.extensions, this.args.includeDefault);
this.serializer = new Serializer(this.extensions, this.args.includeDefault);
const state = EditorState.create({ schema: this.schema, plugins });
this.view = new EditorView(container, {
state,
nodeViews: extractNodeViews(this.extensions),
attributes: { class: this.args.class },
editable: () => this.args.disabled !== true,
dispatchTransaction: (tr) => {
this.view.updateState(this.view.state.apply(tr));
if (tr.docChanged && tr.getMeta("addToHistory") !== false) {
// If this gets expensive, we can debounce it
const value = this.serializer.convert(this.view.state.doc);
this.#lastSerialized = value;
this.args.change?.({ target: { value } });
}
},
handleDOMEvents: {
focus: () => {
this.args.focusIn?.();
return false;
},
blur: () => {
next(() => this.args.focusOut?.());
return false;
},
},
handleKeyDown: (view, event) => {
// suppress if Enter/Tab and the autocomplete is open
return (
AUTOCOMPLETE_KEY_DOWN_SUPPRESS.includes(event.key) &&
!!document.querySelector(".autocomplete")
);
},
});
this.textManipulation = new TextManipulation(getOwner(this), {
schema: this.schema,
view: this.view,
convertFromMarkdown: this.convertFromMarkdown,
convertToMarkdown: this.serializer.convert.bind(this.serializer),
});
this.#destructor = this.args.onSetup?.(this.textManipulation);
this.convertFromValue();
}
@bind
convertFromMarkdown(markdown) {
try {
return this.parser.convert(this.schema, markdown);
} catch (e) {
next(() => this.dialog.alert(e.message));
throw e;
}
}
@bind
convertFromValue() {
// Ignore the markdown we just serialized
if (this.args.value === this.#lastSerialized) {
return;
}
const doc = this.convertFromMarkdown(this.args.value);
const tr = this.view.state.tr;
tr.replaceWith(0, this.view.state.doc.content.size, doc.content).setMeta(
"addToHistory",
false
);
this.view.updateState(this.view.state.apply(tr));
}
@action
teardown() {
this.#destructor?.();
this.view.destroy();
}
@action
updateContext(element, [key, value]) {
this.view.dispatch(
this.view.state.tr
.setMeta("addToHistory", false)
.setMeta("discourseContextChanged", { key, value })
);
}
<template>
<div
class="ProseMirror-container"
{{didInsert this.setup}}
{{didUpdate this.convertFromValue @value}}
{{didUpdate this.updateContext "placeholder" @placeholder}}
{{willDestroy this.teardown}}
>
</div>
</template>
}

View File

@ -0,0 +1,128 @@
import {
InputRule,
inputRules,
smartQuotes,
textblockTypeInputRule,
wrappingInputRule,
} from "prosemirror-inputrules";
export function buildInputRules(extensions, schema, includeDefault = true) {
const rules = [];
if (includeDefault) {
rules.push(
// TODO(renato) smartQuotes should respect `markdown_typographer_quotation_marks`
...smartQuotes,
...[
wrappingInputRule(/^\s*>\s$/, schema.nodes.blockquote),
orderedListRule(schema.nodes.ordered_list),
bulletListRule(schema.nodes.bullet_list),
textblockTypeInputRule(/^```$/, schema.nodes.code_block),
textblockTypeInputRule(/^ {4}$/, schema.nodes.code_block),
headingRule(schema.nodes.heading, 6),
markInputRule(/\*\*([^*]+)\*\*$/, schema.marks.strong),
markInputRule(/(?<=^|\s)__([^_]+)__$/, schema.marks.strong),
markInputRule(/(?:^|(?<!\*))\*([^*]+)\*$/, schema.marks.em),
markInputRule(/(?<=^|\s)_([^_]+)_$/, schema.marks.em),
markInputRule(/`([^`]+)`$/, schema.marks.code),
]
);
}
rules.push(...extractInputRules(extensions, schema));
return inputRules({ rules });
}
function extractInputRules(extensions, schema) {
return extensions.flatMap(({ inputRules: extensionRules }) =>
extensionRules ? processInputRule(extensionRules, schema) : []
);
}
function processInputRule(inputRule, schema) {
if (inputRule instanceof Array) {
return inputRule.map((rule) => processInputRule(rule, schema));
}
if (inputRule instanceof Function) {
inputRule = inputRule({ schema, markInputRule });
}
if (inputRule instanceof InputRule) {
return inputRule;
}
if (
inputRule.match instanceof RegExp &&
inputRule.handler instanceof Function
) {
return new InputRule(inputRule.match, inputRule.handler, inputRule.options);
}
throw new Error("Input rule must have a match regex and a handler function");
}
function orderedListRule(nodeType) {
return wrappingInputRule(
/^(\d+)\.\s$/,
nodeType,
(match) => ({ order: +match[1] }),
(match, node) => node.childCount + node.attrs.order === +match[1]
);
}
function bulletListRule(nodeType) {
return wrappingInputRule(/^\s*([-+*])\s$/, nodeType);
}
function headingRule(nodeType, maxLevel) {
return textblockTypeInputRule(
new RegExp("^(#{1," + maxLevel + "})\\s$"),
nodeType,
(match) => ({ level: match[1].length })
);
}
// https://discuss.prosemirror.net/t/input-rules-for-wrapping-marks/537
function markInputRule(regexp, markType, getAttrs) {
return new InputRule(regexp, (state, match, start, end) => {
const attrs = getAttrs instanceof Function ? getAttrs(match) : getAttrs;
const tr = state.tr;
if (state.doc.rangeHasMark(start, end, markType)) {
return false;
}
if (match[1]) {
let textStart = start + match[0].indexOf(match[1]);
let textEnd = textStart + match[1].length;
if (textEnd < end) {
tr.delete(textEnd, end);
}
if (textStart > start) {
tr.delete(start, textStart);
}
end = start + match[1].length;
tr.addMark(start, end, markType.create(attrs));
tr.removeStoredMark(markType);
} else {
tr.delete(start, end);
tr.insertText(" ");
tr.addMark(start, start + 1, markType.create(attrs));
tr.removeStoredMark(markType);
tr.insertText(" ");
tr.setSelection(
state.selection.constructor.create(tr.doc, start, start + 1)
);
}
return tr;
});
}

View File

@ -0,0 +1,77 @@
import {
chainCommands,
exitCode,
selectParentNode,
setBlockType,
} from "prosemirror-commands";
import { redo, undo } from "prosemirror-history";
import { undoInputRule } from "prosemirror-inputrules";
import { splitListItem } from "prosemirror-schema-list";
const isMac =
typeof navigator !== "undefined"
? /Mac|iP(hone|[oa]d)/.test(navigator.platform)
: false;
export function buildKeymap(
extensions,
schema,
initialKeymap,
params,
includeDefault = true
) {
const keys = {
...initialKeymap,
...extractKeymap(extensions, params),
};
keys["Mod-z"] = undo;
keys["Shift-Mod-z"] = redo;
keys["Backspace"] = undoInputRule;
if (!isMac) {
keys["Mod-y"] = redo;
}
keys["Escape"] = selectParentNode;
// The above keys are always included
if (!includeDefault) {
return keys;
}
keys["Shift-Enter"] = chainCommands(exitCode, (state, dispatch) => {
if (dispatch) {
dispatch(
state.tr
.replaceSelectionWith(schema.nodes.hard_break.create())
.scrollIntoView()
);
}
return true;
});
keys["Mod-Shift-0"] = setBlockType(schema.nodes.paragraph);
keys["Enter"] = splitListItem(schema.nodes.list_item);
for (let level = 1; level <= 6; level++) {
keys["Mod-Shift-" + level] = setBlockType(schema.nodes.heading, { level });
}
keys["Mod-Shift-_"] = (state, dispatch) => {
dispatch?.(
state.tr
.replaceSelectionWith(schema.nodes.horizontal_rule.create())
.scrollIntoView()
);
return true;
};
return keys;
}
function extractKeymap(extensions, params) {
return {
...extensions.map(({ keymap }) => {
return keymap instanceof Function ? keymap(params) : keymap;
}),
};
}

View File

@ -0,0 +1,84 @@
import { defaultMarkdownParser, MarkdownParser } from "prosemirror-markdown";
import { parse } from "../lib/markdown-it";
// TODO(renato): We need a workaround for this parsing issue:
// https://github.com/ProseMirror/prosemirror-markdown/issues/82
// a solution may be a markStack in the state ignoring nested marks
export default class Parser {
#multipleParseSpecs = {};
constructor(extensions, includeDefault = true) {
this.parseTokens = includeDefault
? {
...defaultMarkdownParser.tokens,
bbcode_b: { mark: "strong" },
bbcode_i: { mark: "em" },
}
: {};
this.postParseTokens = includeDefault
? { softbreak: (state) => state.addNode(state.schema.nodes.hard_break) }
: {};
for (const [key, value] of Object.entries(
this.#extractParsers(extensions)
)) {
// Not a ParseSpec
if (typeof value === "function") {
this.postParseTokens[key] = value;
} else {
this.parseTokens[key] = value;
}
}
}
convert(schema, text) {
const parser = new MarkdownParser(schema, { parse }, this.parseTokens);
// Adding function parse handlers directly
Object.assign(parser.tokenHandlers, this.postParseTokens);
return parser.parse(text);
}
#extractParsers(extensions) {
const parsers = {};
for (const { parse: parseObj } of extensions) {
if (!parseObj) {
continue;
}
for (const [token, parseSpec] of Object.entries(parseObj)) {
if (parsers[token] !== undefined) {
if (this.#multipleParseSpecs[token] === undefined) {
// switch to use multipleParseSpecs
this.#multipleParseSpecs[token] = [parsers[token]];
parsers[token] = this.#multipleParser(token);
}
this.#multipleParseSpecs[token].push(parseSpec);
continue;
}
parsers[token] = parseSpec;
}
}
return parsers;
}
#multipleParser(tokenName) {
return (state, token, tokens, i) => {
const parseSpecs = this.#multipleParseSpecs[tokenName];
for (const parseSpec of parseSpecs) {
if (parseSpec(state, token, tokens, i)) {
return;
}
}
throw new Error(
`No parser processed ${tokenName} token for tag: ${
token.tag
}, attrs: ${JSON.stringify(token.attrs)}`
);
};
}
}

View File

@ -0,0 +1,49 @@
import { Plugin } from "prosemirror-state";
export function extractNodeViews(extensions) {
/** @type {Record<string, import('prosemirror-view').NodeViewConstructor>} */
const allNodeViews = {};
for (const { nodeViews } of extensions) {
if (nodeViews) {
for (const [name, NodeViewClass] of Object.entries(nodeViews)) {
allNodeViews[name] = (...args) => new NodeViewClass(...args);
}
}
}
return allNodeViews;
}
export function extractPlugins(extensions, params, view) {
return (
extensions
.flatMap((extension) => extension.plugins || [])
.flatMap((plugin) => processPlugin(plugin, params, view))
// filter async plugins from initial load
.filter(Boolean)
);
}
function processPlugin(pluginArg, params, handleAsyncPlugin) {
if (typeof pluginArg === "function") {
const ret = pluginArg(params);
if (ret instanceof Promise) {
ret.then((plugin) => handleAsyncPlugin(processPlugin(plugin, params)));
return;
}
return processPlugin(ret, params, handleAsyncPlugin);
}
if (pluginArg instanceof Array) {
return pluginArg.map((plugin) =>
processPlugin(plugin, params, handleAsyncPlugin)
);
}
if (pluginArg instanceof Plugin) {
return pluginArg;
}
return new Plugin(pluginArg);
}

View File

@ -0,0 +1,41 @@
import OrderedMap from "orderedmap";
import { schema as defaultMarkdownSchema } from "prosemirror-markdown";
import { Schema } from "prosemirror-model";
export function createSchema(extensions, includeDefault = true) {
let nodes = includeDefault
? defaultMarkdownSchema.spec.nodes
: new OrderedMap([]);
let marks = includeDefault
? defaultMarkdownSchema.spec.marks
: new OrderedMap([]);
for (const [type, spec] of Object.entries(extractNodes(extensions))) {
nodes = nodes.update(type, spec);
}
for (const [type, spec] of Object.entries(extractMarks(extensions))) {
marks = spec.before
? marks.addBefore(spec.before, type, spec)
: marks.update(type, spec);
}
return new Schema({ nodes, marks });
}
function extractNodes(extensions) {
const nodes = {};
for (const extension of extensions) {
Object.assign(nodes, extension.nodeSpec);
}
return nodes;
}
function extractMarks(extensions) {
const marks = {};
for (const extension of extensions) {
Object.assign(marks, extension.markSpec);
}
return marks;
}

View File

@ -0,0 +1,36 @@
import {
defaultMarkdownSerializer,
MarkdownSerializer,
} from "prosemirror-markdown";
export default class Serializer {
#pmSerializer;
constructor(extensions, 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.#pmSerializer = new MarkdownSerializer(this.nodes, this.marks);
}
convert(doc) {
return this.#pmSerializer.serialize(doc);
}
#extractNodeSerializers(extensions) {
for (const { serializeNode } of extensions) {
Object.assign(this.nodes, serializeNode);
}
}
#extractMarkSerializers(extensions) {
for (const { serializeMark } of extensions) {
Object.assign(this.marks, serializeMark);
}
}
}

View File

@ -0,0 +1,56 @@
/**
* This extension is considered a "core" extension, it's autoloaded by ProsemirrorEditor
*
* @type {RichEditorExtension}
*/
const extension = {
plugins({
pmState: { Plugin },
pmView: { Decoration, DecorationSet },
getContext,
}) {
let placeholder;
return new Plugin({
view() {
placeholder = getContext().placeholder;
return {};
},
state: {
init() {
return placeholder;
},
apply(tr) {
const contextChanged = tr.getMeta("discourseContextChanged");
if (contextChanged?.key === "placeholder") {
placeholder = contextChanged.value;
}
return placeholder;
},
},
props: {
decorations(state) {
const { $head } = state.selection;
if (
state.doc.childCount === 1 &&
state.doc.firstChild === $head.parent &&
isEmptyParagraph($head.parent)
) {
const decoration = Decoration.node($head.before(), $head.after(), {
"data-placeholder": this.getState(state),
});
return DecorationSet.create(state.doc, [decoration]);
}
},
},
});
},
};
function isEmptyParagraph(node) {
return node.type.name === "paragraph" && node.nodeSize === 2;
}
export default extension;

View File

@ -0,0 +1,11 @@
import { registerRichEditorExtension } from "discourse/lib/composer/rich-editor-extensions";
/**
* List of default extensions
* ProsemirrorEditor autoloads them when includeDefault=true (the default)
*
* @type {RichEditorExtension[]}
*/
const defaultExtensions = [];
defaultExtensions.forEach(registerRichEditorExtension);

View File

@ -0,0 +1,27 @@
import { buildEngine } from "discourse/static/markdown-it";
import loadPluginFeatures from "discourse/static/markdown-it/features";
import defaultFeatures from "discourse-markdown-it/features/index";
let engine;
function getEngine() {
engine ??= buildEngine({
featuresOverride: [...defaultFeatures, ...loadPluginFeatures()]
.map(({ id }) => id)
// Avoid oneboxing when parsing, we'll handle that separately
.filter((id) => id !== "onebox"),
});
return engine;
}
export const parse = (text) => getEngine().parse(text);
export const getLinkify = () => getEngine().linkify;
export const isBoundary = (str, index) =>
!str ||
getEngine().options.engine.utils.isWhiteSpace(str.charCodeAt(index)) ||
getEngine().options.engine.utils.isPunctChar(
String.fromCharCode(str.charCodeAt(index))
);

View File

@ -0,0 +1 @@
export { getLinkify, isBoundary } from "../lib/markdown-it";

View File

@ -0,0 +1,490 @@
// @ts-check
import { setOwner } from "@ember/owner";
import { next } from "@ember/runloop";
import $ from "jquery";
import { lift, setBlockType, toggleMark, wrapIn } from "prosemirror-commands";
import { liftListItem, sinkListItem } from "prosemirror-schema-list";
import { TextSelection } from "prosemirror-state";
import { bind } from "discourse/lib/decorators";
import { i18n } from "discourse-i18n";
/**
* @typedef {import("discourse/lib/composer/text-manipulation").TextManipulation} TextManipulation
* @typedef {import("discourse/lib/composer/text-manipulation").AutocompleteHandler} AutocompleteHandler
* @typedef {import("discourse/lib/composer/text-manipulation").PlaceholderHandler} PlaceholderHandler
*/
/** @implements {TextManipulation} */
export default class ProsemirrorTextManipulation {
allowPreview = false;
/** @type {import("prosemirror-model").Schema} */
schema;
/** @type {import("prosemirror-view").EditorView} */
view;
/** @type {PlaceholderHandler} */
placeholder;
/** @type {AutocompleteHandler} */
autocompleteHandler;
convertFromMarkdown;
convertToMarkdown;
constructor(owner, { schema, view, convertFromMarkdown, convertToMarkdown }) {
setOwner(this, owner);
this.schema = schema;
this.view = view;
this.convertFromMarkdown = convertFromMarkdown;
this.convertToMarkdown = convertToMarkdown;
this.placeholder = new ProsemirrorPlaceholderHandler({
schema,
view,
convertFromMarkdown,
});
this.autocompleteHandler = new ProsemirrorAutocompleteHandler({
schema,
view,
convertFromMarkdown,
});
}
getSelected() {
const start = this.view.state.selection.from;
const end = this.view.state.selection.to;
const value = this.view.state.doc.textBetween(start, end, " ", " ");
return {
start,
end,
pre: "",
value,
post: "",
};
}
focus() {
this.view.focus();
}
blurAndFocus() {
this.focus();
}
putCursorAtEnd() {
this.focus();
next(() => (this.view.dom.scrollTop = this.view.dom.scrollHeight));
}
autocomplete(options) {
// @ts-ignore
$(this.view.dom).autocomplete(
options instanceof Object
? { textHandler: this.autocompleteHandler, ...options }
: options
);
}
applySurroundSelection(head, tail, exampleKey) {
this.applySurround(this.getSelected(), head, tail, exampleKey);
}
applySurround(sel, head, tail, exampleKey) {
const applySurroundMap = {
italic_text: this.schema.marks.em,
bold_text: this.schema.marks.strong,
code_title: this.schema.marks.code,
};
if (applySurroundMap[exampleKey]) {
toggleMark(applySurroundMap[exampleKey])(
this.view.state,
this.view.dispatch
);
return;
}
const text = head + i18n(`composer.${exampleKey}`) + tail;
const doc = this.convertFromMarkdown(text);
this.view.dispatch(
this.view.state.tr.replaceWith(sel.start, sel.end, doc.content.firstChild)
);
}
addText(sel, text) {
const doc = this.convertFromMarkdown(text);
// assumes it returns a single block node
const content =
doc.content.firstChild.type.name === "paragraph"
? doc.content.firstChild.content
: doc.content.firstChild;
this.view.dispatch(
this.view.state.tr.replaceWith(sel.start, sel.end, content)
);
this.focus();
}
insertBlock(block) {
const doc = this.convertFromMarkdown(block);
const node = doc.content.firstChild;
const tr = this.view.state.tr.replaceSelectionWith(node);
if (!tr.selection.$from.nodeAfter) {
tr.setSelection(new TextSelection(tr.doc.resolve(tr.selection.from + 1)));
}
this.view.dispatch(tr);
this.focus();
}
applyList(_selection, head, exampleKey) {
let command;
const isInside = (type) => {
const $from = this.view.state.selection.$from;
for (let depth = $from.depth; depth > 0; depth--) {
const parent = $from.node(depth);
if (parent.type === type) {
return true;
}
}
return false;
};
if (exampleKey === "list_item") {
const nodeType =
head === "* "
? this.schema.nodes.bullet_list
: this.schema.nodes.ordered_list;
command = isInside(this.schema.nodes.list_item) ? lift : wrapIn(nodeType);
} else if (exampleKey === "blockquote_text") {
command = isInside(this.schema.nodes.blockquote)
? lift
: wrapIn(this.schema.nodes.blockquote);
} else {
throw new Error("Unknown exampleKey");
}
command?.(this.view.state, this.view.dispatch);
}
formatCode() {
let command;
const selection = this.view.state.selection;
if (selection.$from.parent.type === this.schema.nodes.code_block) {
command = setBlockType(this.schema.nodes.paragraph);
} else if (
selection.$from.pos !== selection.$to.pos &&
selection.$from.parent === selection.$to.parent
) {
command = toggleMark(this.schema.marks.code);
} else {
command = setBlockType(this.schema.nodes.code_block);
}
command?.(this.view.state, this.view.dispatch);
}
@bind
emojiSelected(code) {
this.view.dispatch(
this.view.state.tr
.replaceSelectionWith(this.schema.nodes.emoji.create({ code }))
.insertText(" ")
);
}
@bind
paste() {
// Intentionally no-op
// Pasting markdown is being handled by the markdown-paste extension
// Pasting a url on top of a text is being handled by the link extension
}
selectText(from, length, opts) {
const tr = this.view.state.tr.setSelection(
new TextSelection(
this.view.state.doc.resolve(from),
this.view.state.doc.resolve(from + length)
)
);
if (opts.scroll) {
tr.scrollIntoView();
}
this.view.dispatch(tr);
}
@bind
inCodeBlock() {
return this.autocompleteHandler.inCodeBlock();
}
indentSelection(direction) {
const { selection } = this.view.state;
const isInsideListItem =
selection.$head.depth > 0 &&
selection.$head.node(-1).type === this.schema.nodes.list_item;
if (isInsideListItem) {
const command =
direction === "right"
? sinkListItem(this.schema.nodes.list_item)
: liftListItem(this.schema.nodes.list_item);
command(this.view.state, this.view.dispatch);
return true;
}
}
insertText(text) {
const doc = this.convertFromMarkdown(text);
this.view.dispatch(
this.view.state.tr
.replaceSelectionWith(doc.content.firstChild)
.scrollIntoView()
);
this.focus();
}
replaceText(oldValue, newValue, opts = {}) {
// Replacing Markdown text is not reliable and should eventually be deprecated
const markdown = this.convertToMarkdown(this.view.state.doc);
const regex = opts.regex || new RegExp(oldValue, "g");
const index = opts.index || 0;
let matchCount = 0;
const newMarkdown = markdown.replace(regex, (match) => {
if (matchCount++ === index) {
return newValue;
}
return match;
});
if (markdown === newMarkdown) {
return;
}
const newDoc = this.convertFromMarkdown(newMarkdown);
if (!newDoc) {
return;
}
const diff = newValue.length - oldValue.length;
const startOffset = this.view.state.selection.from + diff;
const endOffset = this.view.state.selection.to + diff;
const tr = this.view.state.tr.replaceWith(
0,
this.view.state.doc.content.size,
newDoc.content
);
if (
!opts.skipNewSelection &&
(opts.forceFocus || this.view.dom === document.activeElement)
) {
const adjustedStart = Math.min(startOffset, tr.doc.content.size);
const adjustedEnd = Math.min(endOffset, tr.doc.content.size);
tr.setSelection(TextSelection.create(tr.doc, adjustedStart, adjustedEnd));
}
this.view.dispatch(tr);
}
toggleDirection() {
this.view.dom.dir = this.view.dom.dir === "rtl" ? "ltr" : "rtl";
}
}
/** @implements {AutocompleteHandler} */
class ProsemirrorAutocompleteHandler {
/** @type {import("prosemirror-view").EditorView} */
view;
/** @type {import("prosemirror-model").Schema} */
schema;
convertFromMarkdown;
constructor({ schema, view, convertFromMarkdown }) {
this.schema = schema;
this.view = view;
this.convertFromMarkdown = convertFromMarkdown;
}
/**
* The textual value of the selected text block
* @returns {string}
*/
getValue() {
return (
(this.view.state.selection.$head.nodeBefore?.textContent ?? "") +
(this.view.state.selection.$head.nodeAfter?.textContent ?? "") || " "
);
}
/**
* Replaces the term between start-end in the currently selected text block
*
* It uses input rules to convert it to a node if possible
*
* @param {number} start
* @param {number} end
* @param {String} term
*/
replaceTerm(start, end, term) {
const node = this.view.state.selection.$head.nodeBefore;
const from = this.view.state.selection.from - node.nodeSize + start;
const to = this.view.state.selection.from - node.nodeSize + end + 1;
const doc = this.convertFromMarkdown(term);
const tr = this.view.state.tr.replaceWith(
from,
to,
doc.content.firstChild.content
);
tr.insertText(" ", tr.selection.from);
this.view.dispatch(tr);
}
/**
* Gets the textual caret position within the selected text block
*
* @returns {number}
*/
getCaretPosition() {
const node = this.view.state.selection.$head.nodeBefore;
if (!node?.isText) {
return 0;
}
return node.nodeSize;
}
getCaretCoords(start) {
const node = this.view.state.selection.$head.nodeBefore;
const pos = this.view.state.selection.from - node.nodeSize + start;
const { left, top } = this.view.coordsAtPos(pos);
const rootRect = this.view.dom.getBoundingClientRect();
return {
left: left - rootRect.left,
top: top - rootRect.top,
};
}
async inCodeBlock() {
return (
this.view.state.selection.$from.parent.type ===
this.schema.nodes.code_block
);
}
}
/** @implements {PlaceholderHandler} */
class ProsemirrorPlaceholderHandler {
view;
schema;
convertFromMarkdown;
constructor({ schema, view, convertFromMarkdown }) {
this.schema = schema;
this.view = view;
this.convertFromMarkdown = convertFromMarkdown;
}
insert(file) {
const isEmptyParagraph =
this.view.state.selection.$from.parent.type.name === "paragraph" &&
this.view.state.selection.$from.parent.nodeSize === 2;
const imageNode = this.schema.nodes.image.create({
src: URL.createObjectURL(file.data),
alt: i18n("uploading_filename", { filename: file.name }),
title: file.id,
width: 120,
"data-placeholder": true,
});
this.view.dispatch(
this.view.state.tr.insert(
this.view.state.selection.from,
isEmptyParagraph
? imageNode
: this.schema.nodes.paragraph.create(null, imageNode)
)
);
}
progress() {}
progressComplete() {}
cancelAll() {
this.view.state.doc.descendants((node, pos) => {
if (
node.type === this.schema.nodes.image &&
node.attrs["data-placeholder"]
) {
this.view.dispatch(this.view.state.tr.delete(pos, pos + node.nodeSize));
}
});
}
cancel(file) {
this.view.state.doc.descendants((node, pos) => {
if (
node.type === this.schema.nodes.image &&
node.attrs["data-placeholder"] &&
node.attrs?.title === file.id
) {
this.view.dispatch(this.view.state.tr.delete(pos, pos + node.nodeSize));
}
});
}
success(file, markdown) {
/** @type {null | { node: import("prosemirror-model").Node, pos: number }} */
let nodeToReplace = null;
this.view.state.doc.descendants((node, pos) => {
if (
node.type === this.schema.nodes.image &&
node.attrs["data-placeholder"] &&
node.attrs?.title === file.id
) {
nodeToReplace = { node, pos };
return false;
}
return true;
});
if (!nodeToReplace) {
return;
}
// keeping compatibility with plugins that change the upload markdown
const doc = this.convertFromMarkdown(markdown);
this.view.dispatch(
this.view.state.tr.replaceWith(
nodeToReplace.pos,
nodeToReplace.pos + nodeToReplace.node.nodeSize,
doc.content.firstChild.content
)
);
}
}

View File

@ -34,7 +34,21 @@
"immer": "^10.1.1",
"jspreadsheet-ce": "^4.15.0",
"morphlex": "^0.0.16",
"pretty-text": "workspace:1.0.0"
"orderedmap": "^2.1.1",
"pretty-text": "workspace:1.0.0",
"prosemirror-commands": "^1.6.0",
"prosemirror-dropcursor": "^1.8.1",
"prosemirror-gapcursor": "^1.3.2",
"prosemirror-highlightjs": "^0.9.1",
"prosemirror-history": "^1.4.1",
"prosemirror-inputrules": "^1.4.0",
"prosemirror-keymap": "^1.2.2",
"prosemirror-markdown": "^1.13.1",
"prosemirror-model": "^1.23.0",
"prosemirror-schema-list": "^1.4.1",
"prosemirror-state": "^1.4.3",
"prosemirror-transform": "^1.10.2",
"prosemirror-view": "^1.34.3"
},
"devDependencies": {
"@babel/core": "^7.26.7",

View File

@ -0,0 +1,64 @@
import { tracked } from "@glimmer/tracking";
import { click, render, settled, waitFor } from "@ember/test-helpers";
import DEditor from "discourse/components/d-editor";
export async function testMarkdown(
assert,
markdown,
expectedHtml,
expectedMarkdown
) {
const self = new (class {
@tracked value = markdown;
@tracked view;
})();
const handleSetup = (textManipulation) => {
self.view = textManipulation.view;
};
await render(<template>
<DEditor
@value={{self.value}}
@processPreview={{false}}
@onSetup={{handleSetup}}
/>
</template>);
await click(".composer-toggle-switch");
await waitFor(".ProseMirror");
await settled();
const editor = document.querySelector(".ProseMirror");
// typeIn for contentEditable isn't reliable, and is slower
const tr = self.view.state.tr;
// insert a paragraph to enforce serialization
tr.insert(
tr.doc.content.size,
self.view.state.schema.node(
"paragraph",
null,
self.view.state.schema.text("X")
)
);
// then delete it
tr.delete(tr.doc.content.size - 3, tr.doc.content.size);
self.view.dispatch(tr);
await settled();
const html = editor.innerHTML
// we don't care about some PM-specifics
.replace(' class="ProseMirror-selectednode"', "")
.replace('<img class="ProseMirror-separator" alt="">', "")
.replace('<br class="ProseMirror-trailingBreak">', "")
// or artifacts
.replace('class=""', "");
assert.strictEqual(html, expectedHtml, `HTML should match for "${markdown}"`);
assert.strictEqual(
self.value,
expectedMarkdown,
`Markdown should match for "${markdown}"`
);
}

View File

@ -0,0 +1,203 @@
import { render } from "@ember/test-helpers";
import { module, test } from "qunit";
import { resetRichEditorExtensions } from "discourse/lib/composer/rich-editor-extensions";
import { withPluginApi } from "discourse/lib/plugin-api";
import ProsemirrorEditor from "discourse/static/prosemirror/components/prosemirror-editor";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import { testMarkdown } from "discourse/tests/helpers/rich-editor-helper";
module("Integration | Component | prosemirror-editor", function (hooks) {
setupRenderingTest(hooks);
hooks.afterEach(function () {
resetRichEditorExtensions();
});
test("renders the editor", async function (assert) {
await render(<template><ProsemirrorEditor /></template>);
assert.dom(".ProseMirror").exists("it renders the ProseMirror editor");
});
test("renders the editor with minimum extensions", async function (assert) {
const minimumExtensions = [
{ nodeSpec: { doc: { content: "inline*" }, text: { group: "inline" } } },
];
await render(<template>
<ProsemirrorEditor
@includeDefault={{false}}
@extensions={{minimumExtensions}}
/>
</template>);
assert.dom(".ProseMirror").exists("it renders the ProseMirror editor");
});
test("supports registered nodeSpec/parser/serializer", async function (assert) {
this.siteSettings.rich_editor = true;
withPluginApi("2.1.0", (api) => {
// Multiple parsers can be registered for the same node type
api.registerRichEditorExtension({
parse: { wrap_open() {}, wrap_close() {} },
});
api.registerRichEditorExtension({
nodeSpec: {
marquee: {
content: "block*",
group: "block",
parseDOM: [{ tag: "marquee" }],
toDOM: () => ["marquee", 0],
},
},
parse: {
wrap_open(state, token) {
if (token.attrGet("data-wrap") === "marquee") {
state.openNode(state.schema.nodes.marquee);
return true;
}
},
wrap_close(state) {
if (state.top().type.name === "marquee") {
state.closeNode();
return true;
}
},
},
serializeNode: {
marquee(state, node) {
state.write("[wrap=marquee]\n");
state.renderContent(node);
state.write("[/wrap]\n\n");
},
},
});
api.registerRichEditorExtension({
parse: { wrap_open() {}, wrap_close() {} },
});
});
await testMarkdown(
assert,
"[wrap=marquee]\nHello\n[wrap=marquee]\nWorld\n[/wrap]\nInner\n[/wrap]\n\nText",
"<marquee><p>Hello</p><marquee><p>World</p></marquee><p>Inner</p></marquee><p>Text</p>",
"[wrap=marquee]\nHello\n\n[wrap=marquee]\nWorld\n\n[/wrap]\n\nInner\n\n[/wrap]\n\nText"
);
});
test("supports registered markSpec/parser/serializer", async function (assert) {
this.siteSettings.rich_editor = true;
withPluginApi("2.1.0", (api) => {
api.registerRichEditorExtension({
// just for testing purpose - our actual hashtag is a node, not a mark
markSpec: {
hashtag: {
parseDOM: [{ tag: "span.hashtag-raw" }],
toDOM: () => ["span", { class: "hashtag-raw" }],
},
},
parse: {
span_open(state, token, tokens, i) {
if (token.attrGet("class") === "hashtag-raw") {
// Remove the # from the content
tokens[i + 1].content = tokens[i + 1].content.slice(1);
state.openMark(state.schema.marks.hashtag.create());
return true;
}
},
span_close(state) {
state.closeMark(state.schema.marks.hashtag);
},
},
serializeMark: { hashtag: { open: "#", close: "" } },
});
});
await testMarkdown(
assert,
"Hello #tag #test",
'<p>Hello <span class="hashtag-raw">tag</span> <span class="hashtag-raw">test</span></p>',
"Hello #tag #test"
);
});
test("supports registered nodeViews", async function (assert) {
this.siteSettings.rich_editor = true;
const state = {};
withPluginApi("2.1.0", (api) => {
api.registerRichEditorExtension({
nodeViews: {
paragraph: class CustomNodeView {
constructor() {
this.dom = document.createElement("p");
this.dom.className = "custom-p";
state.updated = true;
}
},
},
});
});
await render(<template><ProsemirrorEditor /></template>);
assert.true(
state.updated,
"it calls the update method of the custom node view"
);
assert.dom(".custom-p").exists("it renders the custom node view for p");
});
test("supports registered plugins with array, object or function", async function (assert) {
this.siteSettings.rich_editor = true;
const state = {};
withPluginApi("2.1.0", (api) => {
// plugins can be an array
api.registerRichEditorExtension({
plugins: [
{
view() {
state.plugin1 = true;
return {};
},
},
],
});
// or the plugin object itself
api.registerRichEditorExtension({
plugins: {
view() {
state.plugin2 = true;
return {};
},
},
});
// or a function that returns the plugin object
api.registerRichEditorExtension({
plugins: ({ pmState: { Plugin } }) =>
new Plugin({
view() {
state.plugin3 = true;
return {};
},
}),
});
});
await render(<template><ProsemirrorEditor /></template>);
assert.true(state.plugin1, "plugin1's view fn was called");
assert.true(state.plugin2, "plugin2's view fn was called");
assert.true(state.plugin3, "plugin3's view fn was called");
});
});

View File

@ -0,0 +1,152 @@
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 - prosemirror-markdown defaults",
function (hooks) {
setupRenderingTest(hooks);
const testCases = {
"paragraphs and hard breaks": [
["Hello", "<p>Hello</p>", "Hello"],
["Hello\nWorld", "<p>Hello<br>World</p>", "Hello\nWorld"],
["Hello\n\nWorld", "<p>Hello</p><p>World</p>", "Hello\n\nWorld"],
],
blockquotes: [
["> Hello", "<blockquote><p>Hello</p></blockquote>", "> Hello"],
[
"> Hello\n> World",
"<blockquote><p>Hello<br>World</p></blockquote>",
"> Hello\n> World",
],
[
"> Hello\n\n> World",
"<blockquote><p>Hello</p></blockquote><blockquote><p>World</p></blockquote>",
"> Hello\n\n> World",
],
],
"horizontal rule": [
[
"Hey\n\n---",
'<p>Hey</p><div contenteditable="false" draggable="true"><hr></div>',
"Hey\n\n---",
],
[
"***",
'<div contenteditable="false" draggable="true"><hr></div>',
"---",
],
],
"heading (level 1-6)": [
["# Hello", "<h1>Hello</h1>", "# Hello"],
["# Hello\nWorld", "<h1>Hello</h1><p>World</p>", "# Hello\n\nWorld"],
["## Hello", "<h2>Hello</h2>", "## Hello"],
["### Hello", "<h3>Hello</h3>", "### Hello"],
["#### Hello", "<h4>Hello</h4>", "#### Hello"],
["##### Hello", "<h5>Hello</h5>", "##### Hello"],
["###### Hello", "<h6>Hello</h6>", "###### Hello"],
],
"code block": [
["```\nHello\n```", "<pre><code>Hello</code></pre>", "```\nHello\n```"],
[
"```\nHello\nWorld\n```",
"<pre><code>Hello\nWorld</code></pre>",
"```\nHello\nWorld\n```",
],
[
"```\nHello\n\nWorld\n```",
"<pre><code>Hello\n\nWorld</code></pre>",
"```\nHello\n\nWorld\n```",
],
[
"```ruby\nHello\n```\n\nWorld",
'<pre data-params="ruby"><code>Hello</code></pre><p>World</p>',
"```ruby\nHello\n```\n\nWorld",
],
],
"ordered lists": [
[
"1. Hello",
`<ol data-tight="true"><li><p>Hello</p></li></ol>`,
"1. Hello",
],
[
"1. Hello\n2. World",
`<ol data-tight="true"><li><p>Hello</p></li><li><p>World</p></li></ol>`,
"1. Hello\n2. World",
],
[
"5. Hello\n\n6. World",
`<ol start="5"><li><p>Hello</p></li><li><p>World</p></li></ol>`,
"5. Hello\n\n6. World",
],
],
"bullet lists": [
[
"* Hello",
'<ul data-tight="true"><li><p>Hello</p></li></ul>',
"* Hello",
],
[
"* Hello\n* World",
'<ul data-tight="true"><li><p>Hello</p></li><li><p>World</p></li></ul>',
"* Hello\n* World",
],
[
"* Hello\n\n* World",
"<ul><li><p>Hello</p></li><li><p>World</p></li></ul>",
"* Hello\n\n* World",
],
],
images: [
[
"![alt](src)",
'<p><img src="src" alt="alt" contenteditable="false" draggable="true"></p>',
"![alt](src)",
],
[
'![alt](src "title")',
'<p><img src="src" alt="alt" title="title" contenteditable="false" draggable="true"></p>',
'![alt](src "title")',
],
],
em: [
["*Hello*", "<p><em>Hello</em></p>", "*Hello*"],
["_Hello_", "<p><em>Hello</em></p>", "*Hello*"],
],
strong: [
["**Hello**", "<p><strong>Hello</strong></p>", "**Hello**"],
["__Hello__", "<p><strong>Hello</strong></p>", "**Hello**"],
],
link: [
["[text](href)", '<p><a href="href">text</a></p>', "[text](href)"],
[
'[text](href "title")',
'<p><a href="href" title="title">text</a></p>',
'[text](href "title")',
],
],
code: [
["Hel`lo wo`rld", "<p>Hel<code>lo wo</code>rld</p>", "Hel`lo wo`rld"],
],
"all marks": [
[
"___[`Hello`](https://example.com)___",
'<p><em><strong><a href="https://example.com"><code>Hello</code></a></strong></em></p>',
"***[`Hello`](https://example.com)***",
],
],
};
Object.entries(testCases).forEach(([name, tests]) => {
tests.forEach(([markdown, expectedHtml, expectedMarkdown]) => {
test(name, async function (assert) {
this.siteSettings.rich_editor = true;
await testMarkdown(assert, markdown, expectedHtml, expectedMarkdown);
});
});
});
}
);

View File

@ -18,6 +18,7 @@
@import "common/topic-timeline";
@import "common/loading-slider";
@import "common/float-kit/_index";
@import "common/rich-editor";
@import "common/login/_index";
@import "common/table-builder/_index";
@import "common/post-action-feedback";

View File

@ -12,6 +12,7 @@
@import "char-counter";
@import "conditional-loading-section";
@import "calendar-date-time-input";
@import "composer-toggle-switch";
@import "convert-to-public-topic-modal";
@import "d-toggle-switch";
@import "date-input";

View File

@ -0,0 +1,93 @@
.composer-toggle-switch {
--toggle-switch-width: 40px;
--toggle-switch-height: 24px;
height: 100%;
grid-column: span 2;
justify-content: center;
display: flex;
align-items: center;
border: 0;
padding: 0;
background: transparent;
&:focus-visible {
outline: none;
}
&[disabled] {
opacity: 0.5;
cursor: not-allowed;
}
&__slider {
display: inline-block;
background: var(--primary-low);
width: var(--toggle-switch-width);
height: var(--toggle-switch-height);
position: relative;
vertical-align: middle;
border-radius: 0.25em;
:focus-visible & {
outline: 2px solid var(--tertiary);
outline-offset: 2px;
}
&::before {
content: "";
display: block;
position: absolute;
background-color: var(--tertiary-low);
width: calc(var(--toggle-switch-height) - 2px);
height: calc(var(--toggle-switch-height) - 4px);
top: 2px;
transition: left 0.25s, right 0.25s;
border-radius: 0.25em;
box-shadow: 0 1px 3px 1px rgba(0, 0, 0, 0.1);
.--markdown & {
left: 2px;
}
.--rte & {
right: 2px;
}
@media (prefers-reduced-motion: reduce) {
transition-duration: 0ms;
}
}
}
&__left-icon,
&__right-icon {
display: inline-block;
position: absolute;
opacity: 0;
transition: opacity 0.25s left 0.25s, right 0.25s;
height: 100%;
width: calc(var(--toggle-switch-height) - 2px);
@media (prefers-reduced-motion: reduce) {
transition-duration: 0ms;
}
.--markdown & {
left: 2px;
}
.--rte & {
right: 2px;
}
&.--active {
opacity: 1;
}
.d-icon {
font-size: var(--font-down-1);
color: var(--primary);
vertical-align: text-bottom;
}
}
}

View File

@ -0,0 +1 @@
@import "rich-editor";

View File

@ -0,0 +1,256 @@
.ProseMirror-container {
margin: 0;
overscroll-behavior: contain;
overflow-anchor: none;
flex-direction: column;
overflow-y: auto;
overflow-x: hidden;
display: flex;
height: 100%;
}
.ProseMirror {
outline: 0;
padding: 0 0.625rem;
> div:first-child,
> details:first-child {
// This is hacky, but helps having the leading gapcursor at the right position
&.ProseMirror-gapcursor {
position: relative;
display: block;
}
margin-top: 0.5rem;
}
h1,
h2,
h3,
h4,
h5,
h6 {
margin: 30px 0 10px;
&:first-child {
margin-top: 10px;
}
}
h1 {
font-size: var(--font-up-3-rem);
}
h2 {
font-size: var(--font-up-2-rem);
}
h3 {
font-size: var(--font-up-1-rem);
}
img {
display: inline-block;
height: auto;
max-width: 100%;
&[data-placeholder="true"] {
animation: placeholder 1.5s infinite;
@keyframes placeholder {
0% {
opacity: 0.6;
}
50% {
opacity: 0.4;
}
100% {
opacity: 0.6;
}
}
}
}
ul,
ol {
padding-left: 1.25em;
&[data-tight="true"] > li > p {
margin-top: 0;
margin-bottom: 0;
}
}
p {
line-height: 1.5;
&:first-child {
margin-top: 0.59rem;
}
}
p[data-placeholder]::before {
pointer-events: none;
position: absolute;
padding-top: 2px;
padding-right: 0.5rem;
content: attr(data-placeholder);
color: var(--primary-400);
line-height: 1.1;
}
del {
background-color: var(--danger-low);
}
ins {
background-color: var(--success-low);
}
mark {
background-color: var(--highlight);
}
td {
padding: 3px 3px 3px 0.5em;
}
th {
padding-bottom: 2px;
font-weight: bold;
color: var(--primary);
}
kbd {
// I believe this shouldn't be `inline-flex` in posts either (test with emojis before/after text to see why),
// but overriding just for the editor for now
display: inline;
padding-top: 0.2rem;
}
.onebox-wrapper {
white-space: normal;
}
.code-block {
position: relative;
}
.code-language-select {
position: absolute;
right: 0.25rem;
top: -0.6rem;
border: 1px solid var(--primary-low);
border-radius: var(--d-border-radius);
background-color: var(--primary-very-low);
color: var(--primary-medium);
font-size: var(--font-down-1-rem);
}
.html-block {
position: relative;
border: 1px dashed var(--primary-low-mid);
&::after {
position: absolute;
right: 0;
top: 0;
content: "HTML";
font-size: var(--font-down-2-rem);
color: var(--primary-low-mid);
z-index: 1;
}
}
}
/*********************************************************
Section below from prosemirror-view/style/prosemirror.css
********************************************************/
.ProseMirror {
position: relative;
}
.ProseMirror {
word-wrap: break-word;
white-space: break-spaces;
}
.ProseMirror pre {
white-space: pre-wrap;
}
.ProseMirror li {
position: relative;
}
.ProseMirror-hideselection *::selection {
background: transparent;
}
.ProseMirror-hideselection {
caret-color: transparent;
}
/* See https://github.com/ProseMirror/prosemirror/issues/1421#issuecomment-1759320191 */
.ProseMirror [draggable][contenteditable="false"] {
user-select: text;
}
.ProseMirror-selectednode {
outline: 2px solid #8cf;
}
/* Make sure li selections wrap around markers */
li.ProseMirror-selectednode {
outline: none;
}
li.ProseMirror-selectednode::after {
content: "";
position: absolute;
left: -32px;
right: -2px;
top: -2px;
bottom: -2px;
border: 2px solid #8cf;
pointer-events: none;
}
/* Protect against generic img rules */
.ProseMirror-separator {
display: inline !important;
border: none !important;
margin: 0 !important;
}
/************************************************************
Section below from prosemirror-gapcursor/style/gapcursor.css
***********************************************************/
.ProseMirror-gapcursor {
display: none;
pointer-events: none;
position: absolute;
}
.ProseMirror-gapcursor::after {
content: "";
display: block;
position: absolute;
top: -2px;
width: 20px;
border-top: 1px solid var(--primary);
animation: ProseMirror-cursor-blink 1.1s steps(2, start) infinite;
}
@keyframes ProseMirror-cursor-blink {
to {
visibility: hidden;
}
}
.ProseMirror-focused .ProseMirror-gapcursor {
display: block;
}

View File

@ -11,6 +11,7 @@
padding: 6px;
padding-bottom: unquote("max(env(safe-area-inset-bottom), 6px)");
flex-grow: 1;
min-height: 0;
}
&.open {

View File

@ -3554,3 +3554,7 @@ experimental:
use_overhauled_theme_color_palette:
default: false
hidden: true
rich_editor:
client: true
default: false
hidden: true

View File

@ -7,6 +7,10 @@ in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [2.1.0] - 2024-01-29
- Added `registerRichEditorExtension` which allows plugins/TCs to register an extension for the rich text editor.
## [2.0.1] - 2025-01-29
- Added `registerReportModeComponent`. This allows plugins to register different report display modes in addition to the built-in core ones like `chart`, `table`, and so on defined in `Report::MODES`.

View File

@ -5,6 +5,7 @@
"module": "esnext",
"moduleResolution": "bundler",
"experimentalDecorators": true,
"allowJs": true,
"paths": {
"discourse/*": [
"./app/assets/javascripts/discourse/app/*"

View File

@ -4,6 +4,7 @@ module SvgSprite
SVG_ICONS =
Set.new(
%w[
a
address-book
align-left
anchor
@ -106,6 +107,7 @@ module SvgSprite
fab-instagram
fab-linkedin-in
fab-linux
fab-markdown
fab-threads
fab-threads-square
fab-twitter

View File

@ -658,7 +658,11 @@ export default class ChatChannel extends Component {
return;
}
if (!target || /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName)) {
if (
!target ||
/^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName) ||
target.closest('[contenteditable="true"]')
) {
return;
}

View File

@ -58,10 +58,10 @@ export default {
const inputs = ["input", "textarea", "select", "button"];
const elementTagName = el?.tagName.toLowerCase();
if (inputs.includes(elementTagName)) {
return false;
}
return true;
return (
inputs.includes(elementTagName) ||
!!el?.closest('[contenteditable="true"]')
);
};
const modifyComposerSelection = (event, type) => {
if (!isChatComposer(event.target)) {
@ -87,7 +87,7 @@ export default {
};
const openChatDrawer = (event) => {
if (!isInputSelection(event.target)) {
if (isInputSelection(event.target)) {
return;
}
event.preventDefault();

176
pnpm-lock.yaml generated
View File

@ -329,9 +329,51 @@ importers:
morphlex:
specifier: ^0.0.16
version: 0.0.16
orderedmap:
specifier: ^2.1.1
version: 2.1.1
pretty-text:
specifier: workspace:1.0.0
version: link:../pretty-text
prosemirror-commands:
specifier: ^1.6.0
version: 1.6.2
prosemirror-dropcursor:
specifier: ^1.8.1
version: 1.8.1
prosemirror-gapcursor:
specifier: ^1.3.2
version: 1.3.2
prosemirror-highlightjs:
specifier: ^0.9.1
version: 0.9.1(highlight.js@11.11.1)(prosemirror-model@1.24.1)(prosemirror-state@1.4.3)(prosemirror-view@1.37.1)
prosemirror-history:
specifier: ^1.4.1
version: 1.4.1
prosemirror-inputrules:
specifier: ^1.4.0
version: 1.4.0
prosemirror-keymap:
specifier: ^1.2.2
version: 1.2.2
prosemirror-markdown:
specifier: ^1.13.1
version: 1.13.1
prosemirror-model:
specifier: ^1.23.0
version: 1.24.1
prosemirror-schema-list:
specifier: ^1.4.1
version: 1.5.0
prosemirror-state:
specifier: ^1.4.3
version: 1.4.3
prosemirror-transform:
specifier: ^1.10.2
version: 1.10.2
prosemirror-view:
specifier: ^1.34.3
version: 1.37.1
devDependencies:
'@babel/core':
specifier: ^7.26.7
@ -6642,6 +6684,9 @@ packages:
ordered-read-streams@1.0.1:
resolution: {integrity: sha512-Z87aSjx3r5c0ZB7bcJqIgIRX5bxR7A4aSzvIbaxd0oTkWBCOoKfuGHiKj60CHVUgg1Phm5yMZzBdt8XqRs73Mw==}
orderedmap@2.1.1:
resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==}
os-locale@5.0.0:
resolution: {integrity: sha512-tqZcNEDAIZKBEPnHPlVDvKrp7NzgLi7jRmhKiUoa2NUmhl13FtkAGLUVR+ZsYvApBQdBfYm43A4tXXQ4IrYLBA==}
engines: {node: '>=10'}
@ -7021,6 +7066,50 @@ packages:
proper-lockfile@4.1.2:
resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==}
prosemirror-commands@1.6.2:
resolution: {integrity: sha512-0nDHH++qcf/BuPLYvmqZTUUsPJUCPBUXt0J1ErTcDIS369CTp773itzLGIgIXG4LJXOlwYCr44+Mh4ii6MP1QA==}
prosemirror-dropcursor@1.8.1:
resolution: {integrity: sha512-M30WJdJZLyXHi3N8vxN6Zh5O8ZBbQCz0gURTfPmTIBNQ5pxrdU7A58QkNqfa98YEjSAL1HUyyU34f6Pm5xBSGw==}
prosemirror-gapcursor@1.3.2:
resolution: {integrity: sha512-wtjswVBd2vaQRrnYZaBCbyDqr232Ed4p2QPtRIUK5FuqHYKGWkEwl08oQM4Tw7DOR0FsasARV5uJFvMZWxdNxQ==}
prosemirror-highlightjs@0.9.1:
resolution: {integrity: sha512-aqcXUCM4Dbc+0DoORrmF4MWrdIJuaJKUpZvzi1xy0HEx06J5vTlnwR25xCUIbxD3ilOtkabB36QqY3WQ03OuzQ==}
peerDependencies:
highlight.js: ^11.6.0
prosemirror-model: ^1.18.1
prosemirror-state: ^1.4.1
prosemirror-view: ^1.26.5
prosemirror-history@1.4.1:
resolution: {integrity: sha512-2JZD8z2JviJrboD9cPuX/Sv/1ChFng+xh2tChQ2X4bB2HeK+rra/bmJ3xGntCcjhOqIzSDG6Id7e8RJ9QPXLEQ==}
prosemirror-inputrules@1.4.0:
resolution: {integrity: sha512-6ygpPRuTJ2lcOXs9JkefieMst63wVJBgHZGl5QOytN7oSZs3Co/BYbc3Yx9zm9H37Bxw8kVzCnDsihsVsL4yEg==}
prosemirror-keymap@1.2.2:
resolution: {integrity: sha512-EAlXoksqC6Vbocqc0GtzCruZEzYgrn+iiGnNjsJsH4mrnIGex4qbLdWWNza3AW5W36ZRrlBID0eM6bdKH4OStQ==}
prosemirror-markdown@1.13.1:
resolution: {integrity: sha512-Sl+oMfMtAjWtlcZoj/5L/Q39MpEnVZ840Xo330WJWUvgyhNmLBLN7MsHn07s53nG/KImevWHSE6fEj4q/GihHw==}
prosemirror-model@1.24.1:
resolution: {integrity: sha512-YM053N+vTThzlWJ/AtPtF1j0ebO36nvbmDy4U7qA2XQB8JVaQp1FmB9Jhrps8s+z+uxhhVTny4m20ptUvhk0Mg==}
prosemirror-schema-list@1.5.0:
resolution: {integrity: sha512-gg1tAfH1sqpECdhIHOA/aLg2VH3ROKBWQ4m8Qp9mBKrOxQRW61zc+gMCI8nh22gnBzd1t2u1/NPLmO3nAa3ssg==}
prosemirror-state@1.4.3:
resolution: {integrity: sha512-goFKORVbvPuAQaXhpbemJFRKJ2aixr+AZMGiquiqKxaucC6hlpHNZHWgz5R7dS4roHiwq9vDctE//CZ++o0W1Q==}
prosemirror-transform@1.10.2:
resolution: {integrity: sha512-2iUq0wv2iRoJO/zj5mv8uDUriOHWzXRnOTVgCzSXnktS/2iQRa3UUQwVlkBlYZFtygw6Nh1+X4mGqoYBINn5KQ==}
prosemirror-view@1.37.1:
resolution: {integrity: sha512-MEAnjOdXU1InxEmhjgmEzQAikaS6lF3hD64MveTPpjOGNTl87iRLA1HupC/DEV6YuK7m4Q9DHFNTjwIVtqz5NA==}
proxy-addr@2.0.7:
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
engines: {node: '>= 0.10'}
@ -7278,6 +7367,9 @@ packages:
resolution: {integrity: sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==}
hasBin: true
rope-sequence@1.3.4:
resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==}
route-recognizer@0.3.4:
resolution: {integrity: sha512-2+MhsfPhvauN1O8KaXpXAOfR/fwe8dnUXVM+xw7yt40lJRfPVQxV6yryZm0cgRvAj5fMF/mdRZbL2ptwbs5i2g==}
@ -8264,6 +8356,9 @@ packages:
vscode-uri@3.0.8:
resolution: {integrity: sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==}
w3c-keyname@2.2.8:
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
w3c-xmlserializer@5.0.0:
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
engines: {node: '>=18'}
@ -15702,6 +15797,8 @@ snapshots:
dependencies:
readable-stream: 2.3.8
orderedmap@2.1.1: {}
os-locale@5.0.0:
dependencies:
execa: 4.1.0
@ -16039,6 +16136,81 @@ snapshots:
retry: 0.12.0
signal-exit: 3.0.7
prosemirror-commands@1.6.2:
dependencies:
prosemirror-model: 1.24.1
prosemirror-state: 1.4.3
prosemirror-transform: 1.10.2
prosemirror-dropcursor@1.8.1:
dependencies:
prosemirror-state: 1.4.3
prosemirror-transform: 1.10.2
prosemirror-view: 1.37.1
prosemirror-gapcursor@1.3.2:
dependencies:
prosemirror-keymap: 1.2.2
prosemirror-model: 1.24.1
prosemirror-state: 1.4.3
prosemirror-view: 1.37.1
prosemirror-highlightjs@0.9.1(highlight.js@11.11.1)(prosemirror-model@1.24.1)(prosemirror-state@1.4.3)(prosemirror-view@1.37.1):
dependencies:
highlight.js: 11.11.1
prosemirror-model: 1.24.1
prosemirror-state: 1.4.3
prosemirror-view: 1.37.1
prosemirror-history@1.4.1:
dependencies:
prosemirror-state: 1.4.3
prosemirror-transform: 1.10.2
prosemirror-view: 1.37.1
rope-sequence: 1.3.4
prosemirror-inputrules@1.4.0:
dependencies:
prosemirror-state: 1.4.3
prosemirror-transform: 1.10.2
prosemirror-keymap@1.2.2:
dependencies:
prosemirror-state: 1.4.3
w3c-keyname: 2.2.8
prosemirror-markdown@1.13.1:
dependencies:
'@types/markdown-it': 14.1.2
markdown-it: 14.0.0
prosemirror-model: 1.24.1
prosemirror-model@1.24.1:
dependencies:
orderedmap: 2.1.1
prosemirror-schema-list@1.5.0:
dependencies:
prosemirror-model: 1.24.1
prosemirror-state: 1.4.3
prosemirror-transform: 1.10.2
prosemirror-state@1.4.3:
dependencies:
prosemirror-model: 1.24.1
prosemirror-transform: 1.10.2
prosemirror-view: 1.37.1
prosemirror-transform@1.10.2:
dependencies:
prosemirror-model: 1.24.1
prosemirror-view@1.37.1:
dependencies:
prosemirror-model: 1.24.1
prosemirror-state: 1.4.3
prosemirror-transform: 1.10.2
proxy-addr@2.0.7:
dependencies:
forwarded: 0.2.0
@ -16330,6 +16502,8 @@ snapshots:
dependencies:
glob: 10.4.5
rope-sequence@1.3.4: {}
route-recognizer@0.3.4: {}
router_js@8.0.6(route-recognizer@0.3.4)(rsvp@4.8.5):
@ -17560,6 +17734,8 @@ snapshots:
vscode-uri@3.0.8: {}
w3c-keyname@2.2.8: {}
w3c-xmlserializer@5.0.0:
dependencies:
xml-name-validator: 5.0.0

View File

@ -31,6 +31,7 @@ def write_config(package_dir, extras: {})
"module" => "esnext",
"moduleResolution" => "bundler",
"experimentalDecorators" => true,
"allowJs" => true,
"paths" => {
**namespaces
.map { |ns, paths| [ns, paths.map { |p| "#{relative(package_dir, p)}/*" }] }

View File

@ -0,0 +1,290 @@
# frozen_string_literal: true
describe "Composer - ProseMirror editor", type: :system do
fab!(:user) { Fabricate(:user, refresh_auto_groups: true) }
fab!(:tag)
let(:composer) { PageObjects::Components::Composer.new }
let(:rich) { composer.rich_editor }
before do
sign_in(user)
SiteSetting.rich_editor = true
end
it "hides the Composer container's preview button" do
page.visit "/new-topic"
expect(composer).to be_opened
expect(composer).to have_composer_preview_toggle
composer.toggle_rich_editor
expect(composer).to have_no_composer_preview_toggle
end
context "with autocomplete" do
it "triggers an autocomplete on mention" do
page.visit "/new-topic"
expect(composer).to be_opened
composer.toggle_rich_editor
composer.type_content("@#{user.username}")
expect(composer).to have_mention_autocomplete
end
it "triggers an autocomplete on hashtag" do
page.visit "/new-topic"
expect(composer).to be_opened
find(".composer-toggle-switch").click
composer.type_content("##{tag.name}")
expect(composer).to have_hashtag_autocomplete
end
it "triggers an autocomplete on emoji" do
page.visit "/new-topic"
expect(composer).to be_opened
composer.toggle_rich_editor
composer.type_content(":smile")
expect(composer).to have_emoji_autocomplete
end
end
context "with inputRules" do
it "supports > to create a blockquote" do
page.visit "/new-topic"
expect(composer).to be_opened
composer.toggle_rich_editor
composer.type_content("> This is a blockquote")
expect(rich).to have_css("blockquote", text: "This is a blockquote")
end
it "supports n. to create an ordered list" do
page.visit "/new-topic"
expect(composer).to be_opened
composer.toggle_rich_editor
composer.type_content("1. Item 1\n5. Item 2")
expect(rich).to have_css("ol li", text: "Item 1")
expect(find("ol ol", text: "Item 2")["start"]).to eq("5")
end
it "supports *, - or + to create an unordered list" do
page.visit "/new-topic"
expect(composer).to be_opened
composer.toggle_rich_editor
composer.type_content("* Item 1\n")
composer.type_content("- Item 2\n")
composer.type_content("+ Item 3")
expect(rich).to have_css("ul ul li", count: 3)
end
it "supports ``` or 4 spaces to create a code block" do
page.visit "/new-topic"
expect(composer).to be_opened
composer.toggle_rich_editor
composer.type_content("```\nThis is a code block")
composer.send_keys(%i[shift enter])
composer.type_content(" This is a code block")
expect(rich).to have_css("pre code", text: "This is a code block", count: 2)
end
it "supports 1-6 #s to create a heading" do
page.visit "/new-topic"
expect(composer).to be_opened
composer.toggle_rich_editor
composer.type_content("# Heading 1\n")
composer.type_content("## Heading 2\n")
composer.type_content("### Heading 3\n")
composer.type_content("#### Heading 4\n")
composer.type_content("##### Heading 5\n")
composer.type_content("###### Heading 6\n")
expect(rich).to have_css("h1", text: "Heading 1")
expect(rich).to have_css("h2", text: "Heading 2")
expect(rich).to have_css("h3", text: "Heading 3")
expect(rich).to have_css("h4", text: "Heading 4")
expect(rich).to have_css("h5", text: "Heading 5")
expect(rich).to have_css("h6", text: "Heading 6")
end
it "supports _ or * to create an italic text" do
page.visit "/new-topic"
expect(composer).to be_opened
composer.toggle_rich_editor
composer.type_content("_This is italic_\n")
composer.type_content("*This is italic*")
expect(rich).to have_css("em", text: "This is italic", count: 2)
end
it "supports __ or ** to create a bold text" do
page.visit "/new-topic"
expect(composer).to be_opened
composer.toggle_rich_editor
composer.type_content("__This is bold__\n")
composer.type_content("**This is bold**")
expect(rich).to have_css("strong", text: "This is bold", count: 2)
end
it "supports ` to create a code text" do
page.visit "/new-topic"
expect(composer).to be_opened
composer.toggle_rich_editor
composer.type_content("`This is code`")
expect(rich).to have_css("code", text: "This is code")
end
end
context "with keymap" do
PLATFORM_KEY_MODIFIER = SystemHelpers::PLATFORM_KEY_MODIFIER
it "supports Ctrl + B to create a bold text" do
page.visit "/new-topic"
expect(composer).to be_opened
composer.toggle_rich_editor
composer.type_content([PLATFORM_KEY_MODIFIER, "b"])
composer.type_content("This is bold")
expect(rich).to have_css("strong", text: "This is bold")
end
it "supports Ctrl + I to create an italic text" do
page.visit "/new-topic"
expect(composer).to be_opened
composer.toggle_rich_editor
composer.type_content([PLATFORM_KEY_MODIFIER, "i"])
composer.type_content("This is italic")
expect(rich).to have_css("em", text: "This is italic")
end
xit "supports Ctrl + K to create a link" do
page.visit "/new-topic"
expect(composer).to be_opened
composer.toggle_rich_editor
page.send_keys([PLATFORM_KEY_MODIFIER, "k"])
page.send_keys("https://www.example.com\t")
page.send_keys("This is a link")
page.send_keys(:enter)
expect(rich).to have_css("a", text: "This is a link")
end
it "supports Ctrl + Shift + 7 to create an ordered list" do
page.visit "/new-topic"
expect(composer).to be_opened
composer.toggle_rich_editor
composer.type_content("Item 1")
composer.send_keys([PLATFORM_KEY_MODIFIER, :shift, "7"])
expect(rich).to have_css("ol li", text: "Item 1")
end
it "supports Ctrl + Shift + 8 to create a bullet list" do
page.visit "/new-topic"
expect(composer).to be_opened
composer.toggle_rich_editor
composer.type_content("Item 1")
composer.send_keys([PLATFORM_KEY_MODIFIER, :shift, "8"])
expect(rich).to have_css("ul li", text: "Item 1")
end
it "supports Ctrl + Shift + 9 to create a blockquote" do
page.visit "/new-topic"
expect(composer).to be_opened
composer.toggle_rich_editor
composer.type_content("This is a blockquote")
composer.send_keys([PLATFORM_KEY_MODIFIER, :shift, "9"])
expect(rich).to have_css("blockquote", text: "This is a blockquote")
end
it "supports Ctrl + Shift + 1-6 for headings, 0 for reset" do
page.visit "/new-topic"
expect(composer).to be_opened
composer.toggle_rich_editor
(1..6).each do |i|
composer.type_content("\nHeading #{i}")
composer.send_keys([PLATFORM_KEY_MODIFIER, :shift, i.to_s])
expect(rich).to have_css("h#{i}", text: "Heading #{i}")
end
composer.send_keys([PLATFORM_KEY_MODIFIER, :shift, "0"])
expect(rich).not_to have_css("h6")
end
it "supports Ctrl + Z and Ctrl + Shift + Z to undo and redo" do
page.visit "/new-topic"
expect(composer).to be_opened
composer.toggle_rich_editor
composer.type_content("This is a test")
composer.send_keys([PLATFORM_KEY_MODIFIER, "z"])
expect(rich).not_to have_css("p", text: "This is a test")
composer.send_keys([PLATFORM_KEY_MODIFIER, :shift, "z"])
expect(rich).to have_css("p", text: "This is a test")
end
it "supports Ctrl + Shift + _ to create a horizontal rule" do
page.visit "/new-topic"
expect(composer).to be_opened
composer.toggle_rich_editor
composer.type_content("This is a test")
composer.send_keys([PLATFORM_KEY_MODIFIER, :shift, "_"])
expect(rich).to have_css("hr")
end
end
end

View File

@ -5,6 +5,12 @@ module PageObjects
class Composer < PageObjects::Components::Base
COMPOSER_ID = "#reply-control"
AUTOCOMPLETE_MENU = ".autocomplete.ac-emoji"
HASHTAG_MENU = ".autocomplete.hashtag-autocomplete"
MENTION_MENU = ".autocomplete.ac-user"
def rich_editor
find(".d-editor-input.ProseMirror")
end
def opened?
page.has_css?("#{COMPOSER_ID}.open")
@ -113,6 +119,14 @@ module PageObjects
page.has_css?(".discard-draft-modal")
end
def has_hashtag_autocomplete?
has_css?(HASHTAG_MENU)
end
def has_mention_autocomplete?
has_css?(MENTION_MENU)
end
def has_emoji_autocomplete?
has_css?(AUTOCOMPLETE_MENU)
end
@ -273,6 +287,11 @@ module PageObjects
select_kit.collapse
end
def toggle_rich_editor
find("#{COMPOSER_ID} .composer-toggle-switch").click
self
end
private
def emoji_preview_selector(emoji)