DEV: refactor textarea from d-editor (#29411)

Refactors the DEditor component making it textarea-agnostic.
This commit is contained in:
Renato Atilio 2024-11-04 12:48:10 -03:00 committed by GitHub
parent 6459ab9320
commit b061fd9cc2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 502 additions and 439 deletions

View File

@ -24,23 +24,12 @@ import {
IMAGE_MARKDOWN_REGEX, IMAGE_MARKDOWN_REGEX,
} from "discourse/lib/uploads"; } from "discourse/lib/uploads";
import UppyComposerUpload from "discourse/lib/uppy/composer-upload"; import UppyComposerUpload from "discourse/lib/uppy/composer-upload";
import userSearch from "discourse/lib/user-search"; import { formatUsername } from "discourse/lib/utilities";
import {
destroyUserStatuses,
initUserStatusHtml,
renderUserStatusHtml,
} from "discourse/lib/user-status-on-autocomplete";
import {
caretPosition,
formatUsername,
inCodeBlock,
} from "discourse/lib/utilities";
import Composer from "discourse/models/composer"; import Composer from "discourse/models/composer";
import { isTesting } from "discourse-common/config/environment"; import { isTesting } from "discourse-common/config/environment";
import { tinyAvatar } from "discourse-common/lib/avatar-utils"; import { tinyAvatar } from "discourse-common/lib/avatar-utils";
import { iconHTML } from "discourse-common/lib/icon-library"; import { iconHTML } from "discourse-common/lib/icon-library";
import discourseLater from "discourse-common/lib/later"; import discourseLater from "discourse-common/lib/later";
import { findRawTemplate } from "discourse-common/lib/raw-templates";
import discourseComputed, { import discourseComputed, {
bind, bind,
debounce, debounce,
@ -191,48 +180,11 @@ export default class ComposerEditor extends Component {
}; };
} }
@bind
_afterMentionComplete(value) {
this.composer.set("reply", value);
// ensures textarea scroll position is correct
schedule("afterRender", () => {
const input = this.element.querySelector(".d-editor-input");
input?.blur();
input?.focus();
});
}
@on("didInsertElement") @on("didInsertElement")
_composerEditorInit() { _composerEditorInit() {
const input = this.element.querySelector(".d-editor-input"); const input = this.element.querySelector(".d-editor-input");
const preview = this.element.querySelector(".d-editor-preview-wrapper"); const preview = this.element.querySelector(".d-editor-preview-wrapper");
if (this.siteSettings.enable_mentions) {
$(input).autocomplete({
template: findRawTemplate("user-selector-autocomplete"),
dataSource: (term) => {
destroyUserStatuses();
return userSearch({
term,
topicId: this.topic?.id,
categoryId: this.topic?.category_id || this.composer?.categoryId,
includeGroups: true,
}).then((result) => {
initUserStatusHtml(getOwner(this), result.users);
return result;
});
},
onRender: (options) => renderUserStatusHtml(options),
key: "@",
transformComplete: (v) => v.username || v.name,
afterComplete: this._afterMentionComplete,
triggerRule: async (textarea) =>
!(await inCodeBlock(textarea.value, caretPosition(textarea))),
onClose: destroyUserStatuses,
});
}
input?.addEventListener( input?.addEventListener(
"scroll", "scroll",
this._throttledSyncEditorAndPreviewScroll this._throttledSyncEditorAndPreviewScroll

View File

@ -0,0 +1,118 @@
import Component from "@glimmer/component";
import { getOwner } from "@ember/owner";
import { service } from "@ember/service";
import ItsATrap from "@discourse/itsatrap";
import { modifier } from "ember-modifier";
import DTextarea from "discourse/components/d-textarea";
import TextareaTextManipulation from "discourse/lib/textarea-text-manipulation";
import { bind } from "discourse-common/utils/decorators";
export default class TextareaEditor extends Component {
@service currentUser;
textarea;
registerTextarea = modifier((textarea) => {
this.textarea = textarea;
this.#itsatrap = new ItsATrap(textarea);
this.textManipulation = new TextareaTextManipulation(getOwner(this), {
markdownOptions: this.args.markdownOptions,
textarea,
});
for (const [key, callback] of Object.entries(this.args.keymap)) {
this.#itsatrap.bind(key, callback);
}
const destructor = this.args.onSetup(this.textManipulation);
this.setupSmartList();
return () => {
this.destroySmartList();
destructor?.();
this.#itsatrap?.destroy();
this.#itsatrap = null;
};
});
#itsatrap;
#handleSmartListAutocomplete = false;
#shiftPressed = false;
@bind
onInputSmartList() {
if (this.#handleSmartListAutocomplete) {
this.textManipulation.maybeContinueList();
}
this.#handleSmartListAutocomplete = false;
}
@bind
onBeforeInputSmartListShiftDetect(event) {
this.#shiftPressed = event.shiftKey;
}
@bind
onBeforeInputSmartList(event) {
// This inputType is much more consistently fired in `beforeinput`
// rather than `input`.
if (!this.#shiftPressed) {
this.#handleSmartListAutocomplete = event.inputType === "insertLineBreak";
}
}
setupSmartList() {
// These must be bound manually because itsatrap does not support
// beforeinput or input events.
//
// beforeinput is better used to detect line breaks because it is
// fired before the actual value of the textarea is changed,
// and sometimes in the input event no `insertLineBreak` event type
// is fired.
//
// c.f. https://developer.mozilla.org/en-US/docs/Web/API/Element/beforeinput_event
if (this.currentUser.user_option.enable_smart_lists) {
this.textarea.addEventListener(
"beforeinput",
this.onBeforeInputSmartList
);
this.textarea.addEventListener(
"keydown",
this.onBeforeInputSmartListShiftDetect
);
this.textarea.addEventListener("input", this.onInputSmartList);
}
}
destroySmartList() {
if (this.currentUser.user_option.enable_smart_lists) {
this.textarea.removeEventListener(
"beforeinput",
this.onBeforeInputSmartList
);
this.textarea.removeEventListener(
"keydown",
this.onBeforeInputSmartListShiftDetect
);
this.textarea.removeEventListener("input", this.onInputSmartList);
}
}
<template>
<DTextarea
@autocomplete="off"
@value={{@value}}
@placeholder={{@placeholder}}
@aria-label={{@placeholder}}
@disabled={{@disabled}}
@input={{@change}}
@focusIn={{@focusIn}}
@focusOut={{@focusOut}}
class="d-editor-input"
@id={{@id}}
{{this.registerTextarea}}
/>
</template>
}

View File

@ -56,17 +56,16 @@
</div> </div>
<ConditionalLoadingSpinner @condition={{this.loading}} /> <ConditionalLoadingSpinner @condition={{this.loading}} />
<DTextarea <this.editorComponent
@autocomplete="off" @onSetup={{this.setupEditor}}
@tabindex={{this.tabindex}} @markdownOptions={{this.markdownOptions}}
@keymap={{this.keymap}}
@value={{this.value}} @value={{this.value}}
@placeholder={{this.placeholderTranslated}} @placeholder={{this.placeholderTranslated}}
@aria-label={{this.placeholderTranslated}}
@disabled={{this.disabled}} @disabled={{this.disabled}}
@input={{this.change}} @change={{this.change}}
@focusIn={{this.handleFocusIn}} @focusIn={{this.handleFocusIn}}
@focusOut={{this.handleFocusOut}} @focusOut={{this.handleFocusOut}}
class="d-editor-input"
@id={{this.textAreaId}} @id={{this.textAreaId}}
/> />
<PopupInputTip @validation={{this.validation}} /> <PopupInputTip @validation={{this.validation}} />

View File

@ -3,33 +3,31 @@ import { action, computed } from "@ember/object";
import { getOwner } from "@ember/owner"; import { getOwner } from "@ember/owner";
import { schedule, scheduleOnce } from "@ember/runloop"; import { schedule, scheduleOnce } from "@ember/runloop";
import { service } from "@ember/service"; import { service } from "@ember/service";
import ItsATrap from "@discourse/itsatrap";
import { classNames } from "@ember-decorators/component"; import { classNames } from "@ember-decorators/component";
import { observes, on } from "@ember-decorators/object"; import { observes, on } from "@ember-decorators/object";
import $ from "jquery";
import { emojiSearch, isSkinTonableEmoji } from "pretty-text/emoji"; import { emojiSearch, isSkinTonableEmoji } from "pretty-text/emoji";
import { translations } from "pretty-text/emoji/data"; import { translations } from "pretty-text/emoji/data";
import { resolveCachedShortUrls } from "pretty-text/upload-short-url"; import { resolveCachedShortUrls } from "pretty-text/upload-short-url";
import { Promise } from "rsvp"; import { Promise } from "rsvp";
import TextareaEditor from "discourse/components/composer/textarea-editor";
import InsertHyperlink from "discourse/components/modal/insert-hyperlink"; import InsertHyperlink from "discourse/components/modal/insert-hyperlink";
import { ajax } from "discourse/lib/ajax"; import { ajax } from "discourse/lib/ajax";
import { SKIP } from "discourse/lib/autocomplete"; import { SKIP } from "discourse/lib/autocomplete";
import { setupHashtagAutocomplete } from "discourse/lib/hashtag-autocomplete"; import Toolbar from "discourse/lib/composer/toolbar";
import { hashtagAutocompleteOptions } from "discourse/lib/hashtag-autocomplete";
import { linkSeenHashtagsInContext } from "discourse/lib/hashtag-decorator"; import { linkSeenHashtagsInContext } from "discourse/lib/hashtag-decorator";
import { wantsNewWindow } from "discourse/lib/intercept-click"; 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 { emojiUrlFor, generateCookFunction } from "discourse/lib/text"; import { emojiUrlFor, generateCookFunction } from "discourse/lib/text";
import { siteDir } from "discourse/lib/text-direction"; import { getHead } from "discourse/lib/textarea-text-manipulation";
import TextareaTextManipulation, { import userSearch from "discourse/lib/user-search";
getHead,
} from "discourse/lib/textarea-text-manipulation";
import { import {
caretPosition, destroyUserStatuses,
inCodeBlock, initUserStatusHtml,
translateModKey, renderUserStatusHtml,
} from "discourse/lib/utilities"; } from "discourse/lib/user-status-on-autocomplete";
import { isTesting } from "discourse-common/config/environment"; import { isTesting } from "discourse-common/config/environment";
import discourseDebounce from "discourse-common/lib/debounce"; import discourseDebounce from "discourse-common/lib/debounce";
import deprecated from "discourse-common/lib/deprecated"; import deprecated from "discourse-common/lib/deprecated";
@ -38,187 +36,10 @@ import { findRawTemplate } from "discourse-common/lib/raw-templates";
import discourseComputed, { bind } from "discourse-common/utils/decorators"; import discourseComputed, { bind } from "discourse-common/utils/decorators";
import I18n from "discourse-i18n"; import I18n from "discourse-i18n";
function getButtonLabel(labelKey, defaultLabel) {
// use the Font Awesome icon if the label matches the default
return I18n.t(labelKey) === defaultLabel ? null : labelKey;
}
const FOUR_SPACES_INDENT = "4-spaces-indent"; const FOUR_SPACES_INDENT = "4-spaces-indent";
let _createCallbacks = []; let _createCallbacks = [];
class Toolbar {
constructor(opts) {
const { siteSettings, capabilities } = opts;
this.shortcuts = {};
this.context = null;
this.handleSmartListAutocomplete = false;
this.groups = [
{ group: "fontStyles", buttons: [] },
{ group: "insertions", buttons: [] },
{ group: "extras", buttons: [] },
];
const boldLabel = getButtonLabel("composer.bold_label", "B");
const boldIcon = boldLabel ? null : "bold";
this.addButton({
id: "bold",
group: "fontStyles",
icon: boldIcon,
label: boldLabel,
shortcut: "B",
preventFocus: true,
trimLeading: true,
perform: (e) => e.applySurround("**", "**", "bold_text"),
});
const italicLabel = getButtonLabel("composer.italic_label", "I");
const italicIcon = italicLabel ? null : "italic";
this.addButton({
id: "italic",
group: "fontStyles",
icon: italicIcon,
label: italicLabel,
shortcut: "I",
preventFocus: true,
trimLeading: true,
perform: (e) => e.applySurround("*", "*", "italic_text"),
});
if (opts.showLink) {
this.addButton({
id: "link",
icon: "link",
group: "insertions",
shortcut: "K",
preventFocus: true,
trimLeading: true,
sendAction: (event) => this.context.send("showLinkModal", event),
});
}
this.addButton({
id: "blockquote",
group: "insertions",
icon: "quote-right",
shortcut: "Shift+9",
preventFocus: true,
perform: (e) =>
e.applyList("> ", "blockquote_text", {
applyEmptyLines: true,
multiline: true,
}),
});
if (!capabilities.touch) {
this.addButton({
id: "code",
group: "insertions",
shortcut: "E",
icon: "code",
preventFocus: true,
trimLeading: true,
action: (...args) => this.context.send("formatCode", args),
});
this.addButton({
id: "bullet",
group: "extras",
icon: "list-ul",
shortcut: "Shift+8",
title: "composer.ulist_title",
preventFocus: true,
perform: (e) => e.applyList("* ", "list_item"),
});
this.addButton({
id: "list",
group: "extras",
icon: "list-ol",
shortcut: "Shift+7",
title: "composer.olist_title",
preventFocus: true,
perform: (e) =>
e.applyList(
(i) => (!i ? "1. " : `${parseInt(i, 10) + 1}. `),
"list_item"
),
});
}
if (siteSettings.support_mixed_text_direction) {
this.addButton({
id: "toggle-direction",
group: "extras",
icon: "right-left",
shortcut: "Shift+6",
title: "composer.toggle_direction",
preventFocus: true,
perform: (e) => e.toggleDirection(),
});
}
this.groups[this.groups.length - 1].lastGroup = true;
}
addButton(buttonAttrs) {
const g = this.groups.findBy("group", buttonAttrs.group);
if (!g) {
throw new Error(`Couldn't find toolbar group ${buttonAttrs.group}`);
}
const createdButton = {
id: buttonAttrs.id,
tabindex: buttonAttrs.tabindex || "-1",
className: buttonAttrs.className || buttonAttrs.id,
label: buttonAttrs.label,
icon: buttonAttrs.icon,
action: (button) => {
buttonAttrs.action
? buttonAttrs.action(button)
: this.context.send("toolbarButton", button);
this.context.appEvents.trigger(
"d-editor:toolbar-button-clicked",
button
);
},
perform: buttonAttrs.perform || function () {},
trimLeading: buttonAttrs.trimLeading,
popupMenu: buttonAttrs.popupMenu || false,
preventFocus: buttonAttrs.preventFocus || false,
condition: buttonAttrs.condition || (() => true),
shortcutAction: buttonAttrs.shortcutAction, // (optional) custom shortcut action
};
if (buttonAttrs.sendAction) {
createdButton.sendAction = buttonAttrs.sendAction;
}
const title = I18n.t(
buttonAttrs.title || `composer.${buttonAttrs.id}_title`
);
if (buttonAttrs.shortcut) {
const shortcutTitle = `${translateModKey(
PLATFORM_KEY_MODIFIER + "+"
)}${translateModKey(buttonAttrs.shortcut)}`;
createdButton.title = `${title} (${shortcutTitle})`;
this.shortcuts[
`${PLATFORM_KEY_MODIFIER}+${buttonAttrs.shortcut}`.toLowerCase()
] = createdButton;
} else {
createdButton.title = title;
}
if (buttonAttrs.unshift) {
g.buttons.unshift(createdButton);
} else {
g.buttons.push(createdButton);
}
}
}
export function addToolbarCallback(func) { export function addToolbarCallback(func) {
_createCallbacks.push(func); _createCallbacks.push(func);
} }
@ -238,6 +59,7 @@ export default class DEditor extends Component {
@service("emoji-store") emojiStore; @service("emoji-store") emojiStore;
@service modal; @service modal;
editorComponent = TextareaEditor;
textManipulation; textManipulation;
ready = false; ready = false;
@ -254,8 +76,6 @@ export default class DEditor extends Component {
}, },
}; };
_itsatrap = null;
@computed("formTemplateIds") @computed("formTemplateIds")
get selectedFormTemplateId() { get selectedFormTemplateId() {
if (this._selectedFormTemplateId) { if (this._selectedFormTemplateId) {
@ -292,7 +112,7 @@ export default class DEditor extends Component {
this.set("ready", true); this.set("ready", true);
if (this.autofocus) { if (this.autofocus) {
this._textarea.focus(); this.textManipulation.focus();
} }
} }
@ -307,28 +127,21 @@ export default class DEditor extends Component {
this._previewMutationObserver = this._disablePreviewTabIndex(); this._previewMutationObserver = this._disablePreviewTabIndex();
this._textarea = this.element.querySelector("textarea.d-editor-input"); // disable clicking on links in the preview
this._$textarea = $(this._textarea); this.element
.querySelector(".d-editor-preview")
.addEventListener("click", this._handlePreviewLinkClick);
``;
}
this.set( get keymap() {
"textManipulation", const keymap = {};
new TextareaTextManipulation(getOwner(this), {
markdownOptions: this.markdownOptions,
textarea: this._textarea,
})
);
this._applyEmojiAutocomplete(this._$textarea);
this._applyHashtagAutocomplete(this._$textarea);
scheduleOnce("afterRender", this, this._readyNow);
this._itsatrap = new ItsATrap(this._textarea);
const shortcuts = this.get("toolbar.shortcuts"); const shortcuts = this.get("toolbar.shortcuts");
Object.keys(shortcuts).forEach((sc) => { Object.keys(shortcuts).forEach((sc) => {
const button = shortcuts[sc]; const button = shortcuts[sc];
this._itsatrap.bind(sc, () => { keymap[sc] = () => {
const customAction = shortcuts[sc].shortcutAction; const customAction = shortcuts[sc].shortcutAction;
if (customAction) { if (customAction) {
@ -338,7 +151,7 @@ export default class DEditor extends Component {
button.action(button); button.action(button);
} }
return false; return false;
}); };
}); });
if (this.popupMenuOptions && this.onPopupMenuAction) { if (this.popupMenuOptions && this.onPopupMenuAction) {
@ -346,99 +159,20 @@ export default class DEditor extends Component {
if (popupButton.shortcut && popupButton.condition) { if (popupButton.shortcut && popupButton.condition) {
const shortcut = const shortcut =
`${PLATFORM_KEY_MODIFIER}+${popupButton.shortcut}`.toLowerCase(); `${PLATFORM_KEY_MODIFIER}+${popupButton.shortcut}`.toLowerCase();
this._itsatrap.bind(shortcut, () => { keymap[shortcut] = () => {
this.onPopupMenuAction(popupButton, this.newToolbarEvent()); this.onPopupMenuAction(popupButton, this.newToolbarEvent());
return false; return false;
}); };
} }
}); });
} }
this._itsatrap.bind("tab", () => keymap["tab"] = () => this.textManipulation.indentSelection("right");
this.textManipulation.indentSelection("right") keymap["shift+tab"] = () => this.textManipulation.indentSelection("left");
); keymap[`${PLATFORM_KEY_MODIFIER}+shift+.`] = () =>
this._itsatrap.bind("shift+tab", () => this.send("insertCurrentTime");
this.textManipulation.indentSelection("left")
);
this._itsatrap.bind(`${PLATFORM_KEY_MODIFIER}+shift+.`, () =>
this.send("insertCurrentTime")
);
// These must be bound manually because itsatrap does not support return keymap;
// beforeinput or input events.
//
// beforeinput is better used to detect line breaks because it is
// fired before the actual value of the textarea is changed,
// and sometimes in the input event no `insertLineBreak` event type
// is fired.
//
// c.f. https://developer.mozilla.org/en-US/docs/Web/API/Element/beforeinput_event
if (this._textarea) {
if (this.currentUser.user_option.enable_smart_lists) {
this._textarea.addEventListener(
"beforeinput",
this.onBeforeInputSmartList
);
this._textarea.addEventListener(
"keydown",
this.onBeforeInputSmartListShiftDetect
);
this._textarea.addEventListener("input", this.onInputSmartList);
}
this.element.addEventListener("paste", this.textManipulation.paste);
}
// disable clicking on links in the preview
this.element
.querySelector(".d-editor-preview")
.addEventListener("click", this._handlePreviewLinkClick);
if (this.composerEvents) {
this.appEvents.on(
"composer:insert-block",
this.textManipulation,
"insertBlock"
);
this.appEvents.on(
"composer:insert-text",
this.textManipulation,
"insertText"
);
this.appEvents.on(
"composer:replace-text",
this.textManipulation,
"replaceText"
);
this.appEvents.on("composer:apply-surround", this, "_applySurround");
this.appEvents.on(
"composer:indent-selected-text",
this.textManipulation,
"indentSelection"
);
}
}
@bind
onBeforeInputSmartListShiftDetect(event) {
this._shiftPressed = event.shiftKey;
}
@bind
onBeforeInputSmartList(event) {
// This inputType is much more consistently fired in `beforeinput`
// rather than `input`.
if (!this._shiftPressed) {
this.handleSmartListAutocomplete = event.inputType === "insertLineBreak";
}
}
@bind
onInputSmartList() {
if (this.handleSmartListAutocomplete) {
this.textManipulation.maybeContinueList();
}
this.handleSmartListAutocomplete = false;
} }
@bind @bind
@ -471,55 +205,12 @@ export default class DEditor extends Component {
@on("willDestroyElement") @on("willDestroyElement")
_shutDown() { _shutDown() {
if (this.composerEvents) {
this.appEvents.off(
"composer:insert-block",
this.textManipulation,
"insertBlock"
);
this.appEvents.off(
"composer:insert-text",
this.textManipulation,
"insertText"
);
this.appEvents.off(
"composer:replace-text",
this.textManipulation,
"replaceText"
);
this.appEvents.off("composer:apply-surround", this, "_applySurround");
this.appEvents.off(
"composer:indent-selected-text",
this.textManipulation,
"indentSelection"
);
}
if (this._textarea) {
if (this.currentUser.user_option.enable_smart_lists) {
this._textarea.removeEventListener(
"beforeinput",
this.onBeforeInputSmartList
);
this._textarea.removeEventListener(
"keydown",
this.onBeforeInputSmartListShiftDetect
);
this._textarea.removeEventListener("input", this.onInputSmartList);
}
}
this._itsatrap?.destroy();
this._itsatrap = null;
this.element this.element
.querySelector(".d-editor-preview") .querySelector(".d-editor-preview")
?.removeEventListener("click", this._handlePreviewLinkClick); ?.removeEventListener("click", this._handlePreviewLinkClick);
this._previewMutationObserver?.disconnect(); this._previewMutationObserver?.disconnect();
this.element.removeEventListener("paste", this.textManipulation.paste);
this._cachedCookFunction = null; this._cachedCookFunction = null;
} }
@ -639,29 +330,30 @@ export default class DEditor extends Component {
} }
_applyHashtagAutocomplete() { _applyHashtagAutocomplete() {
setupHashtagAutocomplete( this.textManipulation.autocomplete(
this.site.hashtag_configurations["topic-composer"], hashtagAutocompleteOptions(
this._$textarea, this.site.hashtag_configurations["topic-composer"],
this.siteSettings, this.siteSettings,
{ {
afterComplete: (value) => { afterComplete: (value) => {
this.set("value", value); this.set("value", value);
schedule( schedule(
"afterRender", "afterRender",
this.textManipulation, this.textManipulation,
this.textManipulation.blurAndFocus this.textManipulation.blurAndFocus
); );
}, },
} }
)
); );
} }
_applyEmojiAutocomplete($textarea) { _applyEmojiAutocomplete() {
if (!this.siteSettings.enable_emoji) { if (!this.siteSettings.enable_emoji) {
return; return;
} }
$textarea.autocomplete({ this.textManipulation.autocomplete({
template: findRawTemplate("emoji-selector-autocomplete"), template: findRawTemplate("emoji-selector-autocomplete"),
key: ":", key: ":",
afterComplete: (text) => { afterComplete: (text) => {
@ -689,7 +381,7 @@ export default class DEditor extends Component {
this.emojiStore.track(v.code); this.emojiStore.track(v.code);
return `${v.code}:`; return `${v.code}:`;
} else { } else {
$textarea.autocomplete({ cancel: true }); this.textManipulation.autocomplete({ cancel: true });
this.set("emojiPickerIsActive", true); this.set("emojiPickerIsActive", true);
this.set("emojiFilter", v.term); this.set("emojiFilter", v.term);
@ -777,8 +469,43 @@ export default class DEditor extends Component {
}); });
}, },
triggerRule: async (textarea) => triggerRule: async () => !(await this.textManipulation.inCodeBlock()),
!(await inCodeBlock(textarea.value, caretPosition(textarea))), });
}
_applyMentionAutocomplete() {
if (!this.siteSettings.enable_mentions) {
return;
}
this.textManipulation.autocomplete({
template: findRawTemplate("user-selector-autocomplete"),
dataSource: (term) => {
destroyUserStatuses();
return userSearch({
term,
topicId: this.topic?.id,
categoryId: this.topic?.category_id || this.composer?.categoryId,
includeGroups: true,
}).then((result) => {
initUserStatusHtml(getOwner(this), result.users);
return result;
});
},
onRender: (options) => renderUserStatusHtml(options),
key: "@",
transformComplete: (v) => v.username || v.name,
afterComplete: (value) => {
this.set("value", value);
schedule(
"afterRender",
this.textManipulation,
this.textManipulation.blurAndFocus
);
},
triggerRule: async () => !(await this.textManipulation.inCodeBlock()),
onClose: destroyUserStatuses,
}); });
} }
@ -810,15 +537,6 @@ export default class DEditor extends Component {
this.textManipulation.applySurround(selected, head, tail, exampleKey, opts); this.textManipulation.applySurround(selected, head, tail, exampleKey, opts);
} }
_toggleDirection() {
let currentDir = this._$textarea.attr("dir")
? this._$textarea.attr("dir")
: siteDir(),
newDir = currentDir === "ltr" ? "rtl" : "ltr";
this._$textarea.attr("dir", newDir).focus();
}
@action @action
rovingButtonBar(event) { rovingButtonBar(event) {
let target = event.target; let target = event.target;
@ -884,7 +602,7 @@ export default class DEditor extends Component {
formatCode: (...args) => this.send("formatCode", args), formatCode: (...args) => this.send("formatCode", args),
addText: (text) => this.textManipulation.addText(selected, text), addText: (text) => this.textManipulation.addText(selected, text),
getText: () => this.value, getText: () => this.value,
toggleDirection: () => this._toggleDirection(), toggleDirection: () => this.textManipulation.toggleDirection(),
replaceText: (oldVal, newVal, opts) => replaceText: (oldVal, newVal, opts) =>
this.textManipulation.replaceText(oldVal, newVal, opts), this.textManipulation.replaceText(oldVal, newVal, opts),
}; };
@ -1009,6 +727,77 @@ export default class DEditor extends Component {
this.set("isEditorFocused", false); this.set("isEditorFocused", false);
} }
@action
setupEditor(textManipulation) {
this.set("textManipulation", textManipulation);
const destroyEvents = this.setupEvents();
this.element.addEventListener("paste", textManipulation.paste);
this._applyEmojiAutocomplete();
this._applyHashtagAutocomplete();
this._applyMentionAutocomplete();
scheduleOnce("afterRender", this, this._readyNow);
return () => {
destroyEvents?.();
this.element?.removeEventListener("paste", textManipulation.paste);
textManipulation.autocomplete("destroy");
};
}
setupEvents() {
const textManipulation = this.textManipulation;
if (this.composerEvents) {
this.appEvents.on(
"composer:insert-block",
textManipulation,
"insertBlock"
);
this.appEvents.on("composer:insert-text", textManipulation, "insertText");
this.appEvents.on(
"composer:replace-text",
textManipulation,
"replaceText"
);
this.appEvents.on("composer:apply-surround", this, "_applySurround");
this.appEvents.on(
"composer:indent-selected-text",
textManipulation,
"indentSelection"
);
return () => {
this.appEvents.off(
"composer:insert-block",
textManipulation,
"insertBlock"
);
this.appEvents.off(
"composer:insert-text",
textManipulation,
"insertText"
);
this.appEvents.off(
"composer:replace-text",
textManipulation,
"replaceText"
);
this.appEvents.off("composer:apply-surround", this, "_applySurround");
this.appEvents.off(
"composer:indent-selected-text",
textManipulation,
"indentSelection"
);
};
}
}
_disablePreviewTabIndex() { _disablePreviewTabIndex() {
const observer = new MutationObserver(function () { const observer = new MutationObserver(function () {
document.querySelectorAll(".d-editor-preview a").forEach((anchor) => { document.querySelectorAll(".d-editor-preview a").forEach((anchor) => {

View File

@ -0,0 +1,179 @@
import { PLATFORM_KEY_MODIFIER } from "discourse/lib/keyboard-shortcuts";
import { translateModKey } from "discourse/lib/utilities";
import I18n from "discourse-i18n";
function getButtonLabel(labelKey, defaultLabel) {
// use the Font Awesome icon if the label matches the default
return I18n.t(labelKey) === defaultLabel ? null : labelKey;
}
export default class Toolbar {
constructor(opts) {
const { siteSettings, capabilities } = opts;
this.shortcuts = {};
this.context = null;
this.groups = [
{ group: "fontStyles", buttons: [] },
{ group: "insertions", buttons: [] },
{ group: "extras", buttons: [] },
];
const boldLabel = getButtonLabel("composer.bold_label", "B");
const boldIcon = boldLabel ? null : "bold";
this.addButton({
id: "bold",
group: "fontStyles",
icon: boldIcon,
label: boldLabel,
shortcut: "B",
preventFocus: true,
trimLeading: true,
perform: (e) => e.applySurround("**", "**", "bold_text"),
});
const italicLabel = getButtonLabel("composer.italic_label", "I");
const italicIcon = italicLabel ? null : "italic";
this.addButton({
id: "italic",
group: "fontStyles",
icon: italicIcon,
label: italicLabel,
shortcut: "I",
preventFocus: true,
trimLeading: true,
perform: (e) => e.applySurround("*", "*", "italic_text"),
});
if (opts.showLink) {
this.addButton({
id: "link",
icon: "link",
group: "insertions",
shortcut: "K",
preventFocus: true,
trimLeading: true,
sendAction: (event) => this.context.send("showLinkModal", event),
});
}
this.addButton({
id: "blockquote",
group: "insertions",
icon: "quote-right",
shortcut: "Shift+9",
preventFocus: true,
perform: (e) =>
e.applyList("> ", "blockquote_text", {
applyEmptyLines: true,
multiline: true,
}),
});
if (!capabilities.touch) {
this.addButton({
id: "code",
group: "insertions",
shortcut: "E",
icon: "code",
preventFocus: true,
trimLeading: true,
action: (...args) => this.context.send("formatCode", args),
});
this.addButton({
id: "bullet",
group: "extras",
icon: "list-ul",
shortcut: "Shift+8",
title: "composer.ulist_title",
preventFocus: true,
perform: (e) => e.applyList("* ", "list_item"),
});
this.addButton({
id: "list",
group: "extras",
icon: "list-ol",
shortcut: "Shift+7",
title: "composer.olist_title",
preventFocus: true,
perform: (e) =>
e.applyList(
(i) => (!i ? "1. " : `${parseInt(i, 10) + 1}. `),
"list_item"
),
});
}
if (siteSettings.support_mixed_text_direction) {
this.addButton({
id: "toggle-direction",
group: "extras",
icon: "right-left",
shortcut: "Shift+6",
title: "composer.toggle_direction",
preventFocus: true,
perform: (e) => e.toggleDirection(),
});
}
this.groups[this.groups.length - 1].lastGroup = true;
}
addButton(buttonAttrs) {
const g = this.groups.findBy("group", buttonAttrs.group);
if (!g) {
throw new Error(`Couldn't find toolbar group ${buttonAttrs.group}`);
}
const createdButton = {
id: buttonAttrs.id,
tabindex: buttonAttrs.tabindex || "-1",
className: buttonAttrs.className || buttonAttrs.id,
label: buttonAttrs.label,
icon: buttonAttrs.icon,
action: (button) => {
buttonAttrs.action
? buttonAttrs.action(button)
: this.context.send("toolbarButton", button);
this.context.appEvents.trigger(
"d-editor:toolbar-button-clicked",
button
);
},
perform: buttonAttrs.perform || function () {},
trimLeading: buttonAttrs.trimLeading,
popupMenu: buttonAttrs.popupMenu || false,
preventFocus: buttonAttrs.preventFocus || false,
condition: buttonAttrs.condition || (() => true),
shortcutAction: buttonAttrs.shortcutAction, // (optional) custom shortcut action
};
if (buttonAttrs.sendAction) {
createdButton.sendAction = buttonAttrs.sendAction;
}
const title = I18n.t(
buttonAttrs.title || `composer.${buttonAttrs.id}_title`
);
if (buttonAttrs.shortcut) {
const shortcutTitle = `${translateModKey(
PLATFORM_KEY_MODIFIER + "+"
)}${translateModKey(buttonAttrs.shortcut)}`;
createdButton.title = `${title} (${shortcutTitle})`;
this.shortcuts[
`${PLATFORM_KEY_MODIFIER}+${buttonAttrs.shortcut}`.toLowerCase()
] = createdButton;
} else {
createdButton.title = title;
}
if (buttonAttrs.unshift) {
g.buttons.unshift(createdButton);
} else {
g.buttons.push(createdButton);
}
}
}

View File

@ -37,15 +37,16 @@ import { findRawTemplate } from "discourse-common/lib/raw-templates";
**/ **/
export function setupHashtagAutocomplete( export function setupHashtagAutocomplete(
contextualHashtagConfiguration, contextualHashtagConfiguration,
$textArea, $textarea,
siteSettings, siteSettings,
autocompleteOptions = {} autocompleteOptions = {}
) { ) {
_setup( $textarea.autocomplete(
contextualHashtagConfiguration, hashtagAutocompleteOptions(
$textArea, contextualHashtagConfiguration,
siteSettings, siteSettings,
autocompleteOptions autocompleteOptions
)
); );
} }
@ -53,13 +54,12 @@ export async function hashtagTriggerRule(textarea) {
return !(await inCodeBlock(textarea.value, caretPosition(textarea))); return !(await inCodeBlock(textarea.value, caretPosition(textarea)));
} }
function _setup( export function hashtagAutocompleteOptions(
contextualHashtagConfiguration, contextualHashtagConfiguration,
$textArea,
siteSettings, siteSettings,
autocompleteOptions autocompleteOptions
) { ) {
$textArea.autocomplete({ return {
template: findRawTemplate("hashtag-autocomplete"), template: findRawTemplate("hashtag-autocomplete"),
key: "#", key: "#",
afterComplete: autocompleteOptions.afterComplete, afterComplete: autocompleteOptions.afterComplete,
@ -75,7 +75,7 @@ function _setup(
}, },
triggerRule: async (textarea, opts) => triggerRule: async (textarea, opts) =>
await hashtagTriggerRule(textarea, opts), await hashtagTriggerRule(textarea, opts),
}); };
} }
let searchCache = {}; let searchCache = {};

View File

@ -1,14 +1,16 @@
import { action } from "@ember/object";
import { setOwner } from "@ember/owner"; import { setOwner } from "@ember/owner";
import { next, 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 { generateLinkifyFunction } from "discourse/lib/text"; import { generateLinkifyFunction } from "discourse/lib/text";
import { siteDir } from "discourse/lib/text-direction";
import toMarkdown from "discourse/lib/to-markdown"; import toMarkdown from "discourse/lib/to-markdown";
import { import {
caretPosition, caretPosition,
clipboardHelpers, clipboardHelpers,
determinePostReplaceSelection, determinePostReplaceSelection,
inCodeBlock,
} from "discourse/lib/utilities"; } from "discourse/lib/utilities";
import { isTesting } from "discourse-common/config/environment"; import { isTesting } from "discourse-common/config/environment";
import { bind } from "discourse-common/utils/decorators"; import { bind } from "discourse-common/utils/decorators";
@ -43,12 +45,14 @@ export default class TextareaTextManipulation {
eventPrefix; eventPrefix;
textarea; textarea;
$textarea;
constructor(owner, { markdownOptions, textarea, eventPrefix = "composer" }) { constructor(owner, { markdownOptions, textarea, eventPrefix = "composer" }) {
setOwner(this, owner); setOwner(this, owner);
this.eventPrefix = eventPrefix; this.eventPrefix = eventPrefix;
this.textarea = textarea; this.textarea = textarea;
this.$textarea = $(textarea);
generateLinkifyFunction(markdownOptions || {}).then((linkify) => { generateLinkifyFunction(markdownOptions || {}).then((linkify) => {
// When pasting links, we should use the same rules to match links as we do when creating links for a cooked post. // When pasting links, we should use the same rules to match links as we do when creating links for a cooked post.
@ -66,6 +70,10 @@ export default class TextareaTextManipulation {
this.textarea?.focus(); this.textarea?.focus();
} }
focus() {
this.textarea.focus();
}
insertBlock(text) { insertBlock(text) {
this._addBlock(this.getSelected(), text); this._addBlock(this.getSelected(), text);
} }
@ -400,7 +408,7 @@ export default class TextareaTextManipulation {
const selected = this.getSelected(null, { lineVal: true }); const selected = this.getSelected(null, { lineVal: true });
const { pre, value: selectedValue, lineVal } = selected; const { pre, value: selectedValue, lineVal } = selected;
const isInlinePasting = pre.match(/[^\n]$/); const isInlinePasting = pre.match(/[^\n]$/);
const isCodeBlock = this.isInsideCodeFence(pre); const isCodeBlock = this.#isAfterStartedCodeFence(pre);
if ( if (
plainText && plainText &&
@ -515,7 +523,10 @@ export default class TextareaTextManipulation {
.join("\n"); .join("\n");
} }
@bind #isAfterStartedCodeFence(beforeText) {
return this.isInside(beforeText, /(^|\n)```/g);
}
maybeContinueList() { maybeContinueList() {
const offset = caretPosition(this.textarea); const offset = caretPosition(this.textarea);
const text = this.value; const text = this.value;
@ -528,7 +539,7 @@ export default class TextareaTextManipulation {
return; return;
} }
if (this.isInsideCodeFence(text.substring(0, offset - 1))) { if (this.#isAfterStartedCodeFence(text.substring(0, offset - 1))) {
return; return;
} }
@ -624,7 +635,6 @@ export default class TextareaTextManipulation {
} }
} }
@bind
indentSelection(direction) { indentSelection(direction) {
if (![INDENT_DIRECTION_LEFT, INDENT_DIRECTION_RIGHT].includes(direction)) { if (![INDENT_DIRECTION_LEFT, INDENT_DIRECTION_RIGHT].includes(direction)) {
return; return;
@ -699,7 +709,7 @@ export default class TextareaTextManipulation {
} }
} }
@action @bind
emojiSelected(code) { emojiSelected(code) {
let selected = this.getSelected(); let selected = this.getSelected();
const captures = selected.pre.match(/\B:(\w*)$/); const captures = selected.pre.match(/\B:(\w*)$/);
@ -720,7 +730,23 @@ export default class TextareaTextManipulation {
} }
} }
isInsideCodeFence(beforeText) { async inCodeBlock() {
return this.isInside(beforeText, /(^|\n)```/g); return inCodeBlock(
this.$textarea.value ?? this.$textarea.val(),
caretPosition(this.$textarea)
);
}
toggleDirection() {
let currentDir = this.$textarea.attr("dir")
? this.$textarea.attr("dir")
: siteDir(),
newDir = currentDir === "ltr" ? "rtl" : "ltr";
this.$textarea.attr("dir", newDir).focus();
}
autocomplete() {
return this.$textarea.autocomplete(...arguments);
} }
} }