mirror of
https://github.com/discourse/discourse.git
synced 2025-05-30 23:12:18 +00: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
|
<ComposerBody
|
||||||
@composer={{this.composer.model}}
|
@composer={{this.composer.model}}
|
||||||
@showPreview={{this.composer.showPreview}}
|
@showPreview={{this.composer.isPreviewVisible}}
|
||||||
@openIfDraft={{this.composer.openIfDraft}}
|
@openIfDraft={{this.composer.openIfDraft}}
|
||||||
@typed={{this.composer.typed}}
|
@typed={{this.composer.typed}}
|
||||||
@cancelled={{this.composer.cancelled}}
|
@cancelled={{this.composer.cancelled}}
|
||||||
@ -8,7 +8,7 @@
|
|||||||
>
|
>
|
||||||
<div class="grippie"></div>
|
<div class="grippie"></div>
|
||||||
{{#if this.composer.visible}}
|
{{#if this.composer.visible}}
|
||||||
{{html-class (if this.composer.showPreview "composer-has-preview")}}
|
{{html-class (if this.composer.isPreviewVisible "composer-has-preview")}}
|
||||||
<ComposerMessages
|
<ComposerMessages
|
||||||
@composer={{this.composer.model}}
|
@composer={{this.composer.model}}
|
||||||
@messageCount={{this.composer.messageCount}}
|
@messageCount={{this.composer.messageCount}}
|
||||||
@ -137,7 +137,7 @@
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
class="title-and-category
|
class="title-and-category
|
||||||
{{if this.composer.showPreview 'with-preview'}}"
|
{{if this.composer.isPreviewVisible 'with-preview'}}"
|
||||||
>
|
>
|
||||||
<ComposerTitle
|
<ComposerTitle
|
||||||
@composer={{this.composer.model}}
|
@composer={{this.composer.model}}
|
||||||
@ -205,7 +205,7 @@
|
|||||||
@connectorTagName="div"
|
@connectorTagName="div"
|
||||||
@outletArgs={{hash
|
@outletArgs={{hash
|
||||||
model=this.composer.model
|
model=this.composer.model
|
||||||
showPreview=this.composer.showPreview
|
showPreview=this.composer.isPreviewVisible
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
@ -295,17 +295,19 @@
|
|||||||
</a>
|
</a>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
<a
|
{{#if this.composer.allowPreview}}
|
||||||
href
|
<a
|
||||||
class="btn btn-default no-text mobile-preview"
|
href
|
||||||
title={{i18n "composer.show_preview"}}
|
class="btn btn-default no-text mobile-preview"
|
||||||
{{on "click" this.composer.togglePreview}}
|
title={{i18n "composer.show_preview"}}
|
||||||
aria-label={{i18n "composer.show_preview"}}
|
{{on "click" this.composer.togglePreview}}
|
||||||
>
|
aria-label={{i18n "composer.show_preview"}}
|
||||||
{{d-icon "desktop"}}
|
>
|
||||||
</a>
|
{{d-icon "desktop"}}
|
||||||
|
</a>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
{{#if this.composer.showPreview}}
|
{{#if this.composer.isPreviewVisible}}
|
||||||
<DButton
|
<DButton
|
||||||
@action={{this.composer.togglePreview}}
|
@action={{this.composer.togglePreview}}
|
||||||
@title="composer.hide_preview"
|
@title="composer.hide_preview"
|
||||||
@ -366,14 +368,14 @@
|
|||||||
{{/if}}
|
{{/if}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{#if this.site.desktopView}}
|
{{#if (and this.composer.allowPreview this.site.desktopView)}}
|
||||||
<DButton
|
<DButton
|
||||||
@action={{this.composer.togglePreview}}
|
@action={{this.composer.togglePreview}}
|
||||||
@translatedTitle={{this.composer.toggleText}}
|
@translatedTitle={{this.composer.toggleText}}
|
||||||
@icon="angles-left"
|
@icon="angles-left"
|
||||||
class={{concat-class
|
class={{concat-class
|
||||||
"btn-transparent btn-mini-toggle toggle-preview"
|
"btn-transparent btn-mini-toggle toggle-preview"
|
||||||
(unless this.composer.showPreview "active")
|
(unless this.composer.isPreviewVisible "active")
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
@ -30,8 +30,7 @@
|
|||||||
@previewUpdated={{action "previewUpdated"}}
|
@previewUpdated={{action "previewUpdated"}}
|
||||||
@markdownOptions={{this.markdownOptions}}
|
@markdownOptions={{this.markdownOptions}}
|
||||||
@extraButtons={{action "extraButtons"}}
|
@extraButtons={{action "extraButtons"}}
|
||||||
@importQuote={{this.composer.importQuote}}
|
@processPreview={{this.composer.isPreviewVisible}}
|
||||||
@processPreview={{this.composer.showPreview}}
|
|
||||||
@validation={{this.validation}}
|
@validation={{this.validation}}
|
||||||
@loading={{this.composer.loading}}
|
@loading={{this.composer.loading}}
|
||||||
@forcePreview={{this.forcePreview}}
|
@forcePreview={{this.forcePreview}}
|
||||||
|
@ -194,17 +194,29 @@ export default class ComposerEditor extends Component {
|
|||||||
this.appEvents.trigger(`${this.composerEventPrefix}:will-open`);
|
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
|
@bind
|
||||||
setupEditor(textManipulation) {
|
setupEditor(textManipulation) {
|
||||||
this.textManipulation = textManipulation;
|
this.textManipulation = textManipulation;
|
||||||
this.uppyComposerUpload.textManipulation = textManipulation;
|
this.uppyComposerUpload.placeholderHandler = textManipulation.placeholder;
|
||||||
|
|
||||||
const input = this.element.querySelector(".d-editor-input");
|
const input = this.element.querySelector(".d-editor-input");
|
||||||
|
|
||||||
input.addEventListener("scroll", this._throttledSyncEditorAndPreviewScroll);
|
input.addEventListener("scroll", this._throttledSyncEditorAndPreviewScroll);
|
||||||
|
|
||||||
// Focus on the body unless we have a title
|
this.composer.set("allowPreview", this.textManipulation.allowPreview);
|
||||||
if (!this.get("composer.model.canEditTitle")) {
|
|
||||||
|
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();
|
this.textManipulation.putCursorAtEnd();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -859,15 +871,6 @@ export default class ComposerEditor extends Component {
|
|||||||
|
|
||||||
@action
|
@action
|
||||||
extraButtons(toolbar) {
|
extraButtons(toolbar) {
|
||||||
toolbar.addButton({
|
|
||||||
id: "quote",
|
|
||||||
group: "fontStyles",
|
|
||||||
icon: "far-comment",
|
|
||||||
sendAction: this.composer.importQuote,
|
|
||||||
title: "composer.quote_post_title",
|
|
||||||
unshift: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
this.composer.allowUpload &&
|
this.composer.allowUpload &&
|
||||||
this.composer.uploadIcon &&
|
this.composer.uploadIcon &&
|
||||||
|
@ -110,7 +110,7 @@ export default class TextareaEditor extends Component {
|
|||||||
@input={{@change}}
|
@input={{@change}}
|
||||||
@focusIn={{@focusIn}}
|
@focusIn={{@focusIn}}
|
||||||
@focusOut={{@focusOut}}
|
@focusOut={{@focusOut}}
|
||||||
class="d-editor-input"
|
class={{@class}}
|
||||||
@id={{@id}}
|
@id={{@id}}
|
||||||
{{this.registerTextarea}}
|
{{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'}}"
|
{{if this.isEditorFocused 'in-focus'}}"
|
||||||
>
|
>
|
||||||
<div class="d-editor-button-bar" role="toolbar">
|
<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 this.toolbar.groups as |group|}}
|
||||||
{{#each group.buttons as |b|}}
|
{{#each group.buttons as |b|}}
|
||||||
{{#if (b.condition this)}}
|
{{#if (b.condition this)}}
|
||||||
@ -40,15 +48,18 @@
|
|||||||
|
|
||||||
<ConditionalLoadingSpinner @condition={{this.loading}} />
|
<ConditionalLoadingSpinner @condition={{this.loading}} />
|
||||||
<this.editorComponent
|
<this.editorComponent
|
||||||
|
@class="d-editor-input"
|
||||||
@onSetup={{this.setupEditor}}
|
@onSetup={{this.setupEditor}}
|
||||||
@markdownOptions={{this.markdownOptions}}
|
@markdownOptions={{this.markdownOptions}}
|
||||||
@keymap={{this.keymap}}
|
@keymap={{this.keymap}}
|
||||||
@value={{this.value}}
|
@value={{this.value}}
|
||||||
@placeholder={{this.placeholderTranslated}}
|
@placeholder={{this.placeholderTranslated}}
|
||||||
@disabled={{this.disabled}}
|
@disabled={{this.disabled}}
|
||||||
@change={{this.change}}
|
@change={{this.onChange}}
|
||||||
@focusIn={{this.handleFocusIn}}
|
@focusIn={{this.handleFocusIn}}
|
||||||
@focusOut={{this.handleFocusOut}}
|
@focusOut={{this.handleFocusOut}}
|
||||||
|
@categoryId={{@categoryId}}
|
||||||
|
@topicId={{@topicId}}
|
||||||
@id={{this.textAreaId}}
|
@id={{this.textAreaId}}
|
||||||
/>
|
/>
|
||||||
<PopupInputTip @validation={{this.validation}} />
|
<PopupInputTip @validation={{this.validation}} />
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { tracked } from "@glimmer/tracking";
|
||||||
import Component from "@ember/component";
|
import Component from "@ember/component";
|
||||||
import { action } from "@ember/object";
|
import { action } from "@ember/object";
|
||||||
import { getOwner } from "@ember/owner";
|
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 { PLATFORM_KEY_MODIFIER } from "discourse/lib/keyboard-shortcuts";
|
||||||
import { linkSeenMentions } from "discourse/lib/link-mentions";
|
import { linkSeenMentions } from "discourse/lib/link-mentions";
|
||||||
import { loadOneboxes } from "discourse/lib/load-oneboxes";
|
import { loadOneboxes } from "discourse/lib/load-oneboxes";
|
||||||
|
import loadRichEditor from "discourse/lib/load-rich-editor";
|
||||||
import { findRawTemplate } from "discourse/lib/raw-templates";
|
import { findRawTemplate } from "discourse/lib/raw-templates";
|
||||||
import { emojiUrlFor, generateCookFunction } from "discourse/lib/text";
|
import { emojiUrlFor, generateCookFunction } from "discourse/lib/text";
|
||||||
import userSearch from "discourse/lib/user-search";
|
import userSearch from "discourse/lib/user-search";
|
||||||
@ -59,8 +61,9 @@ export default class DEditor extends Component {
|
|||||||
@service modal;
|
@service modal;
|
||||||
@service menu;
|
@service menu;
|
||||||
|
|
||||||
editorComponent = TextareaEditor;
|
@tracked editorComponent;
|
||||||
textManipulation;
|
/** @type {TextManipulation} */
|
||||||
|
@tracked textManipulation;
|
||||||
|
|
||||||
ready = false;
|
ready = false;
|
||||||
lastSel = null;
|
lastSel = null;
|
||||||
@ -74,10 +77,19 @@ export default class DEditor extends Component {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
init() {
|
async init() {
|
||||||
super.init(...arguments);
|
super.init(...arguments);
|
||||||
|
|
||||||
this.register = getRegister(this);
|
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")
|
@discourseComputed("placeholder")
|
||||||
@ -630,9 +642,15 @@ export default class DEditor extends Component {
|
|||||||
this.set("isEditorFocused", false);
|
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
|
@action
|
||||||
setupEditor(textManipulation) {
|
setupEditor(textManipulation) {
|
||||||
this.set("textManipulation", textManipulation);
|
this.textManipulation = textManipulation;
|
||||||
|
|
||||||
const destroyEvents = this.setupEvents();
|
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() {
|
setupEvents() {
|
||||||
const textManipulation = this.textManipulation;
|
const textManipulation = this.textManipulation;
|
||||||
|
|
||||||
|
@ -114,6 +114,7 @@ export default function (options) {
|
|||||||
const isInput = me[0].tagName === "INPUT" && !options.treatAsTextarea;
|
const isInput = me[0].tagName === "INPUT" && !options.treatAsTextarea;
|
||||||
let inputSelectedItems = [];
|
let inputSelectedItems = [];
|
||||||
|
|
||||||
|
/** @type {AutocompleteHandler} */
|
||||||
options.textHandler ??= new TextareaAutocompleteHandler(me[0]);
|
options.textHandler ??= new TextareaAutocompleteHandler(me[0]);
|
||||||
|
|
||||||
function handlePaste() {
|
function handlePaste() {
|
||||||
@ -249,14 +250,14 @@ export default function (options) {
|
|||||||
options.textHandler.getCaretPosition();
|
options.textHandler.getCaretPosition();
|
||||||
}
|
}
|
||||||
|
|
||||||
options.textHandler.replaceTerm({
|
options.textHandler.replaceTerm(
|
||||||
start: completeStart,
|
completeStart,
|
||||||
end: completeEnd,
|
completeEnd,
|
||||||
term: (options.preserveKey ? options.key || "" : "") + term,
|
(options.preserveKey ? options.key || "" : "") + term
|
||||||
});
|
);
|
||||||
|
|
||||||
if (options && options.afterComplete) {
|
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 (
|
if (
|
||||||
(term.length !== 0 && term.trim().length === 0) ||
|
(term.length !== 0 && term.trim().length === 0) ||
|
||||||
// close unless the caret is at the end of a word, like #line|<-
|
// 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();
|
closeAutocomplete();
|
||||||
return null;
|
return null;
|
||||||
@ -549,11 +552,11 @@ export default function (options) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let cp = options.textHandler.getCaretPosition();
|
let cp = options.textHandler.getCaretPosition();
|
||||||
const key = options.textHandler.value[cp - 1];
|
const key = options.textHandler.getValue()[cp - 1];
|
||||||
|
|
||||||
if (options.key) {
|
if (options.key) {
|
||||||
if (options.onKeyUp && key !== 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())) {
|
if (match && (await checkTriggerRule())) {
|
||||||
completeStart = cp - match[0].length;
|
completeStart = cp - match[0].length;
|
||||||
@ -565,7 +568,7 @@ export default function (options) {
|
|||||||
|
|
||||||
if (completeStart === null && cp > 0) {
|
if (completeStart === null && cp > 0) {
|
||||||
if (key === options.key) {
|
if (key === options.key) {
|
||||||
let prevChar = options.textHandler.value.charAt(cp - 2);
|
let prevChar = options.textHandler.getValue().charAt(cp - 2);
|
||||||
if (
|
if (
|
||||||
(!prevChar || ALLOWED_LETTERS_REGEXP.test(prevChar)) &&
|
(!prevChar || ALLOWED_LETTERS_REGEXP.test(prevChar)) &&
|
||||||
(await checkTriggerRule())
|
(await checkTriggerRule())
|
||||||
@ -575,11 +578,17 @@ export default function (options) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (completeStart !== null) {
|
} else if (completeStart !== null) {
|
||||||
let term = options.textHandler.value.substring(
|
let term = options.textHandler
|
||||||
completeStart + (options.key ? 1 : 0),
|
.getValue()
|
||||||
cp
|
.substring(completeStart + (options.key ? 1 : 0), cp);
|
||||||
);
|
if (
|
||||||
updateAutoComplete(dataSource(term, options));
|
!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) {
|
while (prevIsGood && caretPos >= 0) {
|
||||||
caretPos -= 1;
|
caretPos -= 1;
|
||||||
prev = options.textHandler.value[caretPos];
|
prev = options.textHandler.getValue()[caretPos];
|
||||||
|
|
||||||
stopFound = prev === options.key;
|
stopFound = prev === options.key;
|
||||||
|
|
||||||
if (stopFound) {
|
if (stopFound) {
|
||||||
prev = options.textHandler.value[caretPos - 1];
|
prev = options.textHandler.getValue()[caretPos - 1];
|
||||||
const shouldTrigger = await checkTriggerRule({ backSpace });
|
const shouldTrigger = await checkTriggerRule({ backSpace });
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@ -614,10 +623,9 @@ export default function (options) {
|
|||||||
(prev === undefined || ALLOWED_LETTERS_REGEXP.test(prev))
|
(prev === undefined || ALLOWED_LETTERS_REGEXP.test(prev))
|
||||||
) {
|
) {
|
||||||
start = caretPos;
|
start = caretPos;
|
||||||
term = options.textHandler.value.substring(
|
term = options.textHandler
|
||||||
caretPos + 1,
|
.getValue()
|
||||||
initialCaretPos
|
.substring(caretPos + 1, initialCaretPos);
|
||||||
);
|
|
||||||
end = caretPos + term.length;
|
end = caretPos + term.length;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -648,7 +656,7 @@ export default function (options) {
|
|||||||
inputSelectedItems.push("");
|
inputSelectedItems.push("");
|
||||||
}
|
}
|
||||||
|
|
||||||
const value = options.textHandler.value;
|
const value = options.textHandler.getValue();
|
||||||
if (typeof inputSelectedItems[0] === "string" && value.length > 0) {
|
if (typeof inputSelectedItems[0] === "string" && value.length > 0) {
|
||||||
inputSelectedItems.pop();
|
inputSelectedItems.pop();
|
||||||
inputSelectedItems.push(value);
|
inputSelectedItems.push(value);
|
||||||
@ -694,7 +702,7 @@ export default function (options) {
|
|||||||
// allow people to right arrow out of completion
|
// allow people to right arrow out of completion
|
||||||
if (
|
if (
|
||||||
e.which === keys.rightArrow &&
|
e.which === keys.rightArrow &&
|
||||||
options.textHandler.value[cp] === " "
|
options.textHandler.getValue()[cp] === " "
|
||||||
) {
|
) {
|
||||||
closeAutocomplete();
|
closeAutocomplete();
|
||||||
return true;
|
return true;
|
||||||
@ -770,10 +778,9 @@ export default function (options) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
term = options.textHandler.value.substring(
|
term = options.textHandler
|
||||||
completeStart + (options.key ? 1 : 0),
|
.getValue()
|
||||||
cp
|
.substring(completeStart + (options.key ? 1 : 0), cp);
|
||||||
);
|
|
||||||
|
|
||||||
if (completeStart === cp && term === options.key) {
|
if (completeStart === cp && term === options.key) {
|
||||||
closeAutocomplete();
|
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("ember-view")) ||
|
||||||
target.classList.contains("lightbox") ||
|
target.classList.contains("lightbox") ||
|
||||||
href.startsWith("mailto:") ||
|
href.startsWith("mailto:") ||
|
||||||
|
target.closest('[contenteditable="true"]') ||
|
||||||
(href.match(/^http[s]?:\/\//i) &&
|
(href.match(/^http[s]?:\/\//i) &&
|
||||||
!href.match(new RegExp("^https?:\\/\\/" + window.location.hostname, "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
|
// docs/CHANGELOG-JAVASCRIPT-PLUGIN-API.md whenever you change the version
|
||||||
// using the format described at https://keepachangelog.com/en/1.0.0/.
|
// 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 $ from "jquery";
|
||||||
import { h } from "virtual-dom";
|
import { h } from "virtual-dom";
|
||||||
@ -66,6 +66,7 @@ import classPrepend, {
|
|||||||
withPrependsRolledBack,
|
withPrependsRolledBack,
|
||||||
} from "discourse/lib/class-prepend";
|
} from "discourse/lib/class-prepend";
|
||||||
import { addPopupMenuOption } from "discourse/lib/composer/custom-popup-menu-options";
|
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 deprecated from "discourse/lib/deprecated";
|
||||||
import { registerDesktopNotificationHandler } from "discourse/lib/desktop-notifications";
|
import { registerDesktopNotificationHandler } from "discourse/lib/desktop-notifications";
|
||||||
import { downloadCalendar } from "discourse/lib/download-calendar";
|
import { downloadCalendar } from "discourse/lib/download-calendar";
|
||||||
@ -3389,6 +3390,17 @@ class PluginApi {
|
|||||||
registerReportModeComponent(mode, componentClass);
|
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) {
|
#deprecatedWidgetOverride(widgetName, override) {
|
||||||
// insert here the code to handle widget deprecations, e.g. for the header widgets we used:
|
// insert here the code to handle widget deprecations, e.g. for the header widgets we used:
|
||||||
// if (DEPRECATED_HEADER_WIDGETS.includes(widgetName)) {
|
// if (DEPRECATED_HEADER_WIDGETS.includes(widgetName)) {
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
|
// @ts-check
|
||||||
import { setOwner } from "@ember/owner";
|
import { setOwner } from "@ember/owner";
|
||||||
import { schedule } from "@ember/runloop";
|
import { next, schedule } from "@ember/runloop";
|
||||||
import { service } from "@ember/service";
|
import { service } from "@ember/service";
|
||||||
import { isEmpty } from "@ember/utils";
|
import { isEmpty } from "@ember/utils";
|
||||||
import $ from "jquery";
|
import $ from "jquery";
|
||||||
@ -19,6 +20,12 @@ import {
|
|||||||
} from "discourse/lib/utilities";
|
} from "discourse/lib/utilities";
|
||||||
import { i18n } from "discourse-i18n";
|
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_LEFT = "left";
|
||||||
const INDENT_DIRECTION_RIGHT = "right";
|
const INDENT_DIRECTION_RIGHT = "right";
|
||||||
|
|
||||||
@ -33,9 +40,13 @@ const OP = {
|
|||||||
|
|
||||||
const FOUR_SPACES_INDENT = "4-spaces-indent";
|
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).
|
* Our head can be a static string or a function that returns a string
|
||||||
export function getHead(head, prev) {
|
* based on input (like for numbered lists).
|
||||||
|
*
|
||||||
|
* @returns {[string, number]}
|
||||||
|
*/
|
||||||
|
function getHead(head, prev) {
|
||||||
if (typeof head === "string") {
|
if (typeof head === "string") {
|
||||||
return [head, head.length];
|
return [head, head.length];
|
||||||
} else {
|
} else {
|
||||||
@ -43,12 +54,15 @@ export function getHead(head, prev) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @implements {TextManipulation} */
|
||||||
export default class TextareaTextManipulation {
|
export default class TextareaTextManipulation {
|
||||||
@service appEvents;
|
@service appEvents;
|
||||||
@service siteSettings;
|
@service siteSettings;
|
||||||
@service capabilities;
|
@service capabilities;
|
||||||
@service currentUser;
|
@service currentUser;
|
||||||
|
|
||||||
|
allowPreview = true;
|
||||||
|
|
||||||
eventPrefix;
|
eventPrefix;
|
||||||
textarea;
|
textarea;
|
||||||
$textarea;
|
$textarea;
|
||||||
@ -816,11 +830,18 @@ export default class TextareaTextManipulation {
|
|||||||
}
|
}
|
||||||
|
|
||||||
putCursorAtEnd() {
|
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) {
|
autocomplete(options) {
|
||||||
return this.$textarea.autocomplete(
|
// @ts-ignore
|
||||||
|
this.$textarea.autocomplete(
|
||||||
options instanceof Object
|
options instanceof Object
|
||||||
? { textHandler: this.autocompleteHandler, ...options }
|
? { textHandler: this.autocompleteHandler, ...options }
|
||||||
: options
|
: options
|
||||||
@ -838,6 +859,7 @@ function insertAtTextarea(textarea, start, end, text) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @implements {AutocompleteHandler} */
|
||||||
export class TextareaAutocompleteHandler {
|
export class TextareaAutocompleteHandler {
|
||||||
textarea;
|
textarea;
|
||||||
$textarea;
|
$textarea;
|
||||||
@ -847,12 +869,13 @@ export class TextareaAutocompleteHandler {
|
|||||||
this.$textarea = $(textarea);
|
this.$textarea = $(textarea);
|
||||||
}
|
}
|
||||||
|
|
||||||
get value() {
|
getValue() {
|
||||||
return this.textarea.value;
|
return this.textarea.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
replaceTerm({ start, end, term }) {
|
replaceTerm(start, end, term) {
|
||||||
const space = this.value.substring(end + 1, end + 2) === " " ? "" : " ";
|
const space =
|
||||||
|
this.getValue().substring(end + 1, end + 2) === " " ? "" : " ";
|
||||||
insertAtTextarea(this.textarea, start, end + 1, term + space);
|
insertAtTextarea(this.textarea, start, end + 1, term + space);
|
||||||
setCaretPosition(this.textarea, start + 1 + term.trim().length);
|
setCaretPosition(this.textarea, start + 1 + term.trim().length);
|
||||||
}
|
}
|
||||||
@ -862,6 +885,7 @@ export class TextareaAutocompleteHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getCaretCoords(start) {
|
getCaretCoords(start) {
|
||||||
|
// @ts-ignore
|
||||||
return this.$textarea.caretPosition({ pos: start + 1 });
|
return this.$textarea.caretPosition({ pos: start + 1 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -873,9 +897,11 @@ export class TextareaAutocompleteHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @implements {PlaceholderHandler} */
|
||||||
class TextareaPlaceholderHandler {
|
class TextareaPlaceholderHandler {
|
||||||
@service composer;
|
@service composer;
|
||||||
|
|
||||||
|
/** @type {TextareaTextManipulation} */
|
||||||
textManipulation;
|
textManipulation;
|
||||||
|
|
||||||
#placeholders = {};
|
#placeholders = {};
|
||||||
|
@ -52,7 +52,8 @@ export default class UppyComposerUpload {
|
|||||||
uploadPreProcessors;
|
uploadPreProcessors;
|
||||||
uploadHandlers;
|
uploadHandlers;
|
||||||
|
|
||||||
textManipulation;
|
/** @type {PlaceholderHandler} */
|
||||||
|
placeholderHandler;
|
||||||
|
|
||||||
#inProgressUploads = [];
|
#inProgressUploads = [];
|
||||||
#bufferedUploadErrors = [];
|
#bufferedUploadErrors = [];
|
||||||
@ -334,7 +335,7 @@ export default class UppyComposerUpload {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
this.textManipulation.placeholder.insert(file);
|
this.placeholderHandler.insert(file);
|
||||||
|
|
||||||
this.appEvents.trigger(
|
this.appEvents.trigger(
|
||||||
`${this.composerEventPrefix}:upload-started`,
|
`${this.composerEventPrefix}:upload-started`,
|
||||||
@ -369,7 +370,7 @@ export default class UppyComposerUpload {
|
|||||||
file,
|
file,
|
||||||
upload.url,
|
upload.url,
|
||||||
() => {
|
() => {
|
||||||
this.textManipulation.placeholder.success(file, markdown);
|
this.placeholderHandler.success(file, markdown);
|
||||||
|
|
||||||
this.appEvents.trigger(
|
this.appEvents.trigger(
|
||||||
`${this.composerEventPrefix}:upload-success`,
|
`${this.composerEventPrefix}:upload-success`,
|
||||||
@ -395,7 +396,7 @@ export default class UppyComposerUpload {
|
|||||||
this.uppyWrapper.uppyInstance.on("cancel-all", () => {
|
this.uppyWrapper.uppyInstance.on("cancel-all", () => {
|
||||||
// Do the manual cancelling work only if the user clicked cancel
|
// Do the manual cancelling work only if the user clicked cancel
|
||||||
if (this.#userCancelled) {
|
if (this.#userCancelled) {
|
||||||
this.textManipulation.placeholder.cancelAll();
|
this.placeholderHandler.cancelAll();
|
||||||
this.#userCancelled = false;
|
this.#userCancelled = false;
|
||||||
this.#reset();
|
this.#reset();
|
||||||
|
|
||||||
@ -480,13 +481,13 @@ export default class UppyComposerUpload {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.uppyWrapper.onPreProcessProgress((file) => {
|
this.uppyWrapper.onPreProcessProgress((file) => {
|
||||||
this.textManipulation.placeholder.progress(file);
|
this.placeholderHandler.progress(file);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.uppyWrapper.onPreProcessComplete(
|
this.uppyWrapper.onPreProcessComplete(
|
||||||
(file) => {
|
(file) => {
|
||||||
run(() => {
|
run(() => {
|
||||||
this.textManipulation.placeholder.progressComplete(file);
|
this.placeholderHandler.progressComplete(file);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
() => {
|
() => {
|
||||||
@ -529,13 +530,13 @@ export default class UppyComposerUpload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#resetUpload(file) {
|
#resetUpload(file) {
|
||||||
this.textManipulation.placeholder.cancel(file);
|
this.placeholderHandler.cancel(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
@bind
|
@bind
|
||||||
_pasteEventListener(event) {
|
_pasteEventListener(event) {
|
||||||
if (
|
if (
|
||||||
document.activeElement !== document.querySelector(this.editorInputClass)
|
!document.querySelector(this.editorInputClass)?.contains(event.target)
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -550,6 +551,7 @@ export default class UppyComposerUpload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (event && event.clipboardData && event.clipboardData.files) {
|
if (event && event.clipboardData && event.clipboardData.files) {
|
||||||
|
event.preventDefault();
|
||||||
this._addFiles([...event.clipboardData.files], { pasted: true });
|
this._addFiles([...event.clipboardData.files], { pasted: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { tracked } from "@glimmer/tracking";
|
||||||
import EmberObject, { action, computed } from "@ember/object";
|
import EmberObject, { action, computed } from "@ember/object";
|
||||||
import { alias, and, or, reads } from "@ember/object/computed";
|
import { alias, and, or, reads } from "@ember/object/computed";
|
||||||
import { cancel, scheduleOnce } from "@ember/runloop";
|
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 getURL from "discourse/lib/get-url";
|
||||||
import { disableImplicitInjections } from "discourse/lib/implicit-injections";
|
import { disableImplicitInjections } from "discourse/lib/implicit-injections";
|
||||||
import { wantsNewWindow } from "discourse/lib/intercept-click";
|
import { wantsNewWindow } from "discourse/lib/intercept-click";
|
||||||
import { buildQuote } from "discourse/lib/quote";
|
|
||||||
import { emojiUnescape } from "discourse/lib/text";
|
import { emojiUnescape } from "discourse/lib/text";
|
||||||
import {
|
import {
|
||||||
authorizesOneOrMoreExtensions,
|
authorizesOneOrMoreExtensions,
|
||||||
@ -106,6 +106,8 @@ export default class ComposerService extends Service {
|
|||||||
@service siteSettings;
|
@service siteSettings;
|
||||||
@service store;
|
@service store;
|
||||||
|
|
||||||
|
@tracked showPreview = true;
|
||||||
|
@tracked allowPreview = true;
|
||||||
checkedMessages = false;
|
checkedMessages = false;
|
||||||
messageCount = null;
|
messageCount = null;
|
||||||
showEditReason = false;
|
showEditReason = false;
|
||||||
@ -119,7 +121,7 @@ export default class ComposerService extends Service {
|
|||||||
uploadProgress;
|
uploadProgress;
|
||||||
topic = null;
|
topic = null;
|
||||||
linkLookup = null;
|
linkLookup = null;
|
||||||
showPreview = true;
|
|
||||||
composerHeight = null;
|
composerHeight = null;
|
||||||
|
|
||||||
@and("site.mobileView", "showPreview") forcePreview;
|
@and("site.mobileView", "showPreview") forcePreview;
|
||||||
@ -135,6 +137,10 @@ export default class ComposerService extends Service {
|
|||||||
return getOwnerWithFallback(this).lookup("controller:topic");
|
return getOwnerWithFallback(this).lookup("controller:topic");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get isPreviewVisible() {
|
||||||
|
return this.showPreview && this.allowPreview;
|
||||||
|
}
|
||||||
|
|
||||||
get isOpen() {
|
get isOpen() {
|
||||||
return this.model?.composeState === Composer.OPEN;
|
return this.model?.composeState === Composer.OPEN;
|
||||||
}
|
}
|
||||||
@ -841,45 +847,6 @@ export default class ComposerService extends Service {
|
|||||||
return false;
|
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
|
@action
|
||||||
saveAction(ignore, event) {
|
saveAction(ignore, event) {
|
||||||
this.save(false, {
|
this.save(false, {
|
||||||
|
@ -4,7 +4,7 @@ import loadPluginFeatures from "./features";
|
|||||||
import MentionsParser from "./mentions-parser";
|
import MentionsParser from "./mentions-parser";
|
||||||
import buildOptions from "./options";
|
import buildOptions from "./options";
|
||||||
|
|
||||||
function buildEngine(options) {
|
export function buildEngine(options) {
|
||||||
return DiscourseMarkdownIt.withCustomFeatures(
|
return DiscourseMarkdownIt.withCustomFeatures(
|
||||||
loadPluginFeatures()
|
loadPluginFeatures()
|
||||||
).withOptions(buildOptions(options));
|
).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",
|
"immer": "^10.1.1",
|
||||||
"jspreadsheet-ce": "^4.15.0",
|
"jspreadsheet-ce": "^4.15.0",
|
||||||
"morphlex": "^0.0.16",
|
"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": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.26.7",
|
"@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/topic-timeline";
|
||||||
@import "common/loading-slider";
|
@import "common/loading-slider";
|
||||||
@import "common/float-kit/_index";
|
@import "common/float-kit/_index";
|
||||||
|
@import "common/rich-editor";
|
||||||
@import "common/login/_index";
|
@import "common/login/_index";
|
||||||
@import "common/table-builder/_index";
|
@import "common/table-builder/_index";
|
||||||
@import "common/post-action-feedback";
|
@import "common/post-action-feedback";
|
||||||
|
@ -12,6 +12,7 @@
|
|||||||
@import "char-counter";
|
@import "char-counter";
|
||||||
@import "conditional-loading-section";
|
@import "conditional-loading-section";
|
||||||
@import "calendar-date-time-input";
|
@import "calendar-date-time-input";
|
||||||
|
@import "composer-toggle-switch";
|
||||||
@import "convert-to-public-topic-modal";
|
@import "convert-to-public-topic-modal";
|
||||||
@import "d-toggle-switch";
|
@import "d-toggle-switch";
|
||||||
@import "date-input";
|
@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: 6px;
|
||||||
padding-bottom: unquote("max(env(safe-area-inset-bottom), 6px)");
|
padding-bottom: unquote("max(env(safe-area-inset-bottom), 6px)");
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.open {
|
&.open {
|
||||||
|
@ -3554,3 +3554,7 @@ experimental:
|
|||||||
use_overhauled_theme_color_palette:
|
use_overhauled_theme_color_palette:
|
||||||
default: false
|
default: false
|
||||||
hidden: true
|
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/),
|
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).
|
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
|
## [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`.
|
- 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",
|
"module": "esnext",
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"experimentalDecorators": true,
|
"experimentalDecorators": true,
|
||||||
|
"allowJs": true,
|
||||||
"paths": {
|
"paths": {
|
||||||
"discourse/*": [
|
"discourse/*": [
|
||||||
"./app/assets/javascripts/discourse/app/*"
|
"./app/assets/javascripts/discourse/app/*"
|
||||||
|
@ -4,6 +4,7 @@ module SvgSprite
|
|||||||
SVG_ICONS =
|
SVG_ICONS =
|
||||||
Set.new(
|
Set.new(
|
||||||
%w[
|
%w[
|
||||||
|
a
|
||||||
address-book
|
address-book
|
||||||
align-left
|
align-left
|
||||||
anchor
|
anchor
|
||||||
@ -106,6 +107,7 @@ module SvgSprite
|
|||||||
fab-instagram
|
fab-instagram
|
||||||
fab-linkedin-in
|
fab-linkedin-in
|
||||||
fab-linux
|
fab-linux
|
||||||
|
fab-markdown
|
||||||
fab-threads
|
fab-threads
|
||||||
fab-threads-square
|
fab-threads-square
|
||||||
fab-twitter
|
fab-twitter
|
||||||
|
@ -658,7 +658,11 @@ export default class ChatChannel extends Component {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!target || /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName)) {
|
if (
|
||||||
|
!target ||
|
||||||
|
/^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName) ||
|
||||||
|
target.closest('[contenteditable="true"]')
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -58,10 +58,10 @@ export default {
|
|||||||
const inputs = ["input", "textarea", "select", "button"];
|
const inputs = ["input", "textarea", "select", "button"];
|
||||||
const elementTagName = el?.tagName.toLowerCase();
|
const elementTagName = el?.tagName.toLowerCase();
|
||||||
|
|
||||||
if (inputs.includes(elementTagName)) {
|
return (
|
||||||
return false;
|
inputs.includes(elementTagName) ||
|
||||||
}
|
!!el?.closest('[contenteditable="true"]')
|
||||||
return true;
|
);
|
||||||
};
|
};
|
||||||
const modifyComposerSelection = (event, type) => {
|
const modifyComposerSelection = (event, type) => {
|
||||||
if (!isChatComposer(event.target)) {
|
if (!isChatComposer(event.target)) {
|
||||||
@ -87,7 +87,7 @@ export default {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const openChatDrawer = (event) => {
|
const openChatDrawer = (event) => {
|
||||||
if (!isInputSelection(event.target)) {
|
if (isInputSelection(event.target)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
176
pnpm-lock.yaml
generated
176
pnpm-lock.yaml
generated
@ -329,9 +329,51 @@ importers:
|
|||||||
morphlex:
|
morphlex:
|
||||||
specifier: ^0.0.16
|
specifier: ^0.0.16
|
||||||
version: 0.0.16
|
version: 0.0.16
|
||||||
|
orderedmap:
|
||||||
|
specifier: ^2.1.1
|
||||||
|
version: 2.1.1
|
||||||
pretty-text:
|
pretty-text:
|
||||||
specifier: workspace:1.0.0
|
specifier: workspace:1.0.0
|
||||||
version: link:../pretty-text
|
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:
|
devDependencies:
|
||||||
'@babel/core':
|
'@babel/core':
|
||||||
specifier: ^7.26.7
|
specifier: ^7.26.7
|
||||||
@ -6642,6 +6684,9 @@ packages:
|
|||||||
ordered-read-streams@1.0.1:
|
ordered-read-streams@1.0.1:
|
||||||
resolution: {integrity: sha512-Z87aSjx3r5c0ZB7bcJqIgIRX5bxR7A4aSzvIbaxd0oTkWBCOoKfuGHiKj60CHVUgg1Phm5yMZzBdt8XqRs73Mw==}
|
resolution: {integrity: sha512-Z87aSjx3r5c0ZB7bcJqIgIRX5bxR7A4aSzvIbaxd0oTkWBCOoKfuGHiKj60CHVUgg1Phm5yMZzBdt8XqRs73Mw==}
|
||||||
|
|
||||||
|
orderedmap@2.1.1:
|
||||||
|
resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==}
|
||||||
|
|
||||||
os-locale@5.0.0:
|
os-locale@5.0.0:
|
||||||
resolution: {integrity: sha512-tqZcNEDAIZKBEPnHPlVDvKrp7NzgLi7jRmhKiUoa2NUmhl13FtkAGLUVR+ZsYvApBQdBfYm43A4tXXQ4IrYLBA==}
|
resolution: {integrity: sha512-tqZcNEDAIZKBEPnHPlVDvKrp7NzgLi7jRmhKiUoa2NUmhl13FtkAGLUVR+ZsYvApBQdBfYm43A4tXXQ4IrYLBA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@ -7021,6 +7066,50 @@ packages:
|
|||||||
proper-lockfile@4.1.2:
|
proper-lockfile@4.1.2:
|
||||||
resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==}
|
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:
|
proxy-addr@2.0.7:
|
||||||
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
|
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
|
||||||
engines: {node: '>= 0.10'}
|
engines: {node: '>= 0.10'}
|
||||||
@ -7278,6 +7367,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==}
|
resolution: {integrity: sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
rope-sequence@1.3.4:
|
||||||
|
resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==}
|
||||||
|
|
||||||
route-recognizer@0.3.4:
|
route-recognizer@0.3.4:
|
||||||
resolution: {integrity: sha512-2+MhsfPhvauN1O8KaXpXAOfR/fwe8dnUXVM+xw7yt40lJRfPVQxV6yryZm0cgRvAj5fMF/mdRZbL2ptwbs5i2g==}
|
resolution: {integrity: sha512-2+MhsfPhvauN1O8KaXpXAOfR/fwe8dnUXVM+xw7yt40lJRfPVQxV6yryZm0cgRvAj5fMF/mdRZbL2ptwbs5i2g==}
|
||||||
|
|
||||||
@ -8264,6 +8356,9 @@ packages:
|
|||||||
vscode-uri@3.0.8:
|
vscode-uri@3.0.8:
|
||||||
resolution: {integrity: sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==}
|
resolution: {integrity: sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==}
|
||||||
|
|
||||||
|
w3c-keyname@2.2.8:
|
||||||
|
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
|
||||||
|
|
||||||
w3c-xmlserializer@5.0.0:
|
w3c-xmlserializer@5.0.0:
|
||||||
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
|
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@ -15702,6 +15797,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
readable-stream: 2.3.8
|
readable-stream: 2.3.8
|
||||||
|
|
||||||
|
orderedmap@2.1.1: {}
|
||||||
|
|
||||||
os-locale@5.0.0:
|
os-locale@5.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
execa: 4.1.0
|
execa: 4.1.0
|
||||||
@ -16039,6 +16136,81 @@ snapshots:
|
|||||||
retry: 0.12.0
|
retry: 0.12.0
|
||||||
signal-exit: 3.0.7
|
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:
|
proxy-addr@2.0.7:
|
||||||
dependencies:
|
dependencies:
|
||||||
forwarded: 0.2.0
|
forwarded: 0.2.0
|
||||||
@ -16330,6 +16502,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
glob: 10.4.5
|
glob: 10.4.5
|
||||||
|
|
||||||
|
rope-sequence@1.3.4: {}
|
||||||
|
|
||||||
route-recognizer@0.3.4: {}
|
route-recognizer@0.3.4: {}
|
||||||
|
|
||||||
router_js@8.0.6(route-recognizer@0.3.4)(rsvp@4.8.5):
|
router_js@8.0.6(route-recognizer@0.3.4)(rsvp@4.8.5):
|
||||||
@ -17560,6 +17734,8 @@ snapshots:
|
|||||||
|
|
||||||
vscode-uri@3.0.8: {}
|
vscode-uri@3.0.8: {}
|
||||||
|
|
||||||
|
w3c-keyname@2.2.8: {}
|
||||||
|
|
||||||
w3c-xmlserializer@5.0.0:
|
w3c-xmlserializer@5.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
xml-name-validator: 5.0.0
|
xml-name-validator: 5.0.0
|
||||||
|
@ -31,6 +31,7 @@ def write_config(package_dir, extras: {})
|
|||||||
"module" => "esnext",
|
"module" => "esnext",
|
||||||
"moduleResolution" => "bundler",
|
"moduleResolution" => "bundler",
|
||||||
"experimentalDecorators" => true,
|
"experimentalDecorators" => true,
|
||||||
|
"allowJs" => true,
|
||||||
"paths" => {
|
"paths" => {
|
||||||
**namespaces
|
**namespaces
|
||||||
.map { |ns, paths| [ns, paths.map { |p| "#{relative(package_dir, p)}/*" }] }
|
.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
|
class Composer < PageObjects::Components::Base
|
||||||
COMPOSER_ID = "#reply-control"
|
COMPOSER_ID = "#reply-control"
|
||||||
AUTOCOMPLETE_MENU = ".autocomplete.ac-emoji"
|
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?
|
def opened?
|
||||||
page.has_css?("#{COMPOSER_ID}.open")
|
page.has_css?("#{COMPOSER_ID}.open")
|
||||||
@ -113,6 +119,14 @@ module PageObjects
|
|||||||
page.has_css?(".discard-draft-modal")
|
page.has_css?(".discard-draft-modal")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def has_hashtag_autocomplete?
|
||||||
|
has_css?(HASHTAG_MENU)
|
||||||
|
end
|
||||||
|
|
||||||
|
def has_mention_autocomplete?
|
||||||
|
has_css?(MENTION_MENU)
|
||||||
|
end
|
||||||
|
|
||||||
def has_emoji_autocomplete?
|
def has_emoji_autocomplete?
|
||||||
has_css?(AUTOCOMPLETE_MENU)
|
has_css?(AUTOCOMPLETE_MENU)
|
||||||
end
|
end
|
||||||
@ -273,6 +287,11 @@ module PageObjects
|
|||||||
select_kit.collapse
|
select_kit.collapse
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def toggle_rich_editor
|
||||||
|
find("#{COMPOSER_ID} .composer-toggle-switch").click
|
||||||
|
self
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def emoji_preview_selector(emoji)
|
def emoji_preview_selector(emoji)
|
||||||
|
Loading…
x
Reference in New Issue
Block a user