mirror of
https://github.com/discourse/discourse.git
synced 2025-04-17 00:39:31 +08:00
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:
parent
6d6e9c174d
commit
0e61565b2b
@ -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}}
|
||||
|
@ -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}}
|
||||
|
@ -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 &&
|
||||
|
@ -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}}
|
||||
/>
|
||||
|
@ -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>
|
||||
}
|
@ -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}} />
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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();
|
||||
|
@ -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;
|
||||
}
|
@ -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}
|
||||
*/
|
@ -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")))
|
||||
) {
|
||||
|
@ -0,0 +1,5 @@
|
||||
export default async function loadRichEditor() {
|
||||
return (
|
||||
await import("discourse/static/prosemirror/components/prosemirror-editor")
|
||||
).default;
|
||||
}
|
@ -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)) {
|
||||
|
@ -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 = {};
|
||||
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
@ -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, {
|
||||
|
@ -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));
|
||||
|
@ -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>
|
||||
}
|
@ -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;
|
||||
});
|
||||
}
|
@ -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;
|
||||
}),
|
||||
};
|
||||
}
|
@ -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)}`
|
||||
);
|
||||
};
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
@ -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;
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
@ -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);
|
@ -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))
|
||||
);
|
@ -0,0 +1 @@
|
||||
export { getLinkify, isBoundary } from "../lib/markdown-it";
|
@ -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
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
@ -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",
|
||||
|
@ -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}"`
|
||||
);
|
||||
}
|
@ -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");
|
||||
});
|
||||
});
|
@ -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: [
|
||||
[
|
||||
"",
|
||||
'<p><img src="src" alt="alt" contenteditable="false" draggable="true"></p>',
|
||||
"",
|
||||
],
|
||||
[
|
||||
'',
|
||||
'<p><img src="src" alt="alt" title="title" contenteditable="false" draggable="true"></p>',
|
||||
'',
|
||||
],
|
||||
],
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
@ -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";
|
||||
|
@ -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";
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
1
app/assets/stylesheets/common/rich-editor/_index.scss
Normal file
1
app/assets/stylesheets/common/rich-editor/_index.scss
Normal file
@ -0,0 +1 @@
|
||||
@import "rich-editor";
|
256
app/assets/stylesheets/common/rich-editor/rich-editor.scss
Normal file
256
app/assets/stylesheets/common/rich-editor/rich-editor.scss
Normal 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;
|
||||
}
|
@ -11,6 +11,7 @@
|
||||
padding: 6px;
|
||||
padding-bottom: unquote("max(env(safe-area-inset-bottom), 6px)");
|
||||
flex-grow: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
&.open {
|
||||
|
@ -3554,3 +3554,7 @@ experimental:
|
||||
use_overhauled_theme_color_palette:
|
||||
default: false
|
||||
hidden: true
|
||||
rich_editor:
|
||||
client: true
|
||||
default: false
|
||||
hidden: true
|
||||
|
@ -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`.
|
||||
|
@ -5,6 +5,7 @@
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"experimentalDecorators": true,
|
||||
"allowJs": true,
|
||||
"paths": {
|
||||
"discourse/*": [
|
||||
"./app/assets/javascripts/discourse/app/*"
|
||||
|
@ -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
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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
176
pnpm-lock.yaml
generated
@ -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
|
||||
|
@ -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)}/*" }] }
|
||||
|
290
spec/system/composer/prosemirror_editor_spec.rb
Normal file
290
spec/system/composer/prosemirror_editor_spec.rb
Normal 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
|
@ -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)
|
||||
|
Loading…
x
Reference in New Issue
Block a user