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,
} from "discourse/lib/uploads";
import UppyComposerUpload from "discourse/lib/uppy/composer-upload";
import userSearch from "discourse/lib/user-search";
import {
destroyUserStatuses,
initUserStatusHtml,
renderUserStatusHtml,
} from "discourse/lib/user-status-on-autocomplete";
import {
caretPosition,
formatUsername,
inCodeBlock,
} from "discourse/lib/utilities";
import { formatUsername } from "discourse/lib/utilities";
import Composer from "discourse/models/composer";
import { isTesting } from "discourse-common/config/environment";
import { tinyAvatar } from "discourse-common/lib/avatar-utils";
import { iconHTML } from "discourse-common/lib/icon-library";
import discourseLater from "discourse-common/lib/later";
import { findRawTemplate } from "discourse-common/lib/raw-templates";
import discourseComputed, {
bind,
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")
_composerEditorInit() {
const input = this.element.querySelector(".d-editor-input");
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(
"scroll",
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>
<ConditionalLoadingSpinner @condition={{this.loading}} />
<DTextarea
@autocomplete="off"
@tabindex={{this.tabindex}}
<this.editorComponent
@onSetup={{this.setupEditor}}
@markdownOptions={{this.markdownOptions}}
@keymap={{this.keymap}}
@value={{this.value}}
@placeholder={{this.placeholderTranslated}}
@aria-label={{this.placeholderTranslated}}
@disabled={{this.disabled}}
@input={{this.change}}
@change={{this.change}}
@focusIn={{this.handleFocusIn}}
@focusOut={{this.handleFocusOut}}
class="d-editor-input"
@id={{this.textAreaId}}
/>
<PopupInputTip @validation={{this.validation}} />

View File

@ -3,33 +3,31 @@ import { action, computed } from "@ember/object";
import { getOwner } from "@ember/owner";
import { schedule, scheduleOnce } from "@ember/runloop";
import { service } from "@ember/service";
import ItsATrap from "@discourse/itsatrap";
import { classNames } from "@ember-decorators/component";
import { observes, on } from "@ember-decorators/object";
import $ from "jquery";
import { emojiSearch, isSkinTonableEmoji } from "pretty-text/emoji";
import { translations } from "pretty-text/emoji/data";
import { resolveCachedShortUrls } from "pretty-text/upload-short-url";
import { Promise } from "rsvp";
import TextareaEditor from "discourse/components/composer/textarea-editor";
import InsertHyperlink from "discourse/components/modal/insert-hyperlink";
import { ajax } from "discourse/lib/ajax";
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 { wantsNewWindow } from "discourse/lib/intercept-click";
import { PLATFORM_KEY_MODIFIER } from "discourse/lib/keyboard-shortcuts";
import { linkSeenMentions } from "discourse/lib/link-mentions";
import { loadOneboxes } from "discourse/lib/load-oneboxes";
import { emojiUrlFor, generateCookFunction } from "discourse/lib/text";
import { siteDir } from "discourse/lib/text-direction";
import TextareaTextManipulation, {
getHead,
} from "discourse/lib/textarea-text-manipulation";
import { getHead } from "discourse/lib/textarea-text-manipulation";
import userSearch from "discourse/lib/user-search";
import {
caretPosition,
inCodeBlock,
translateModKey,
} from "discourse/lib/utilities";
destroyUserStatuses,
initUserStatusHtml,
renderUserStatusHtml,
} from "discourse/lib/user-status-on-autocomplete";
import { isTesting } from "discourse-common/config/environment";
import discourseDebounce from "discourse-common/lib/debounce";
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 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";
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) {
_createCallbacks.push(func);
}
@ -238,6 +59,7 @@ export default class DEditor extends Component {
@service("emoji-store") emojiStore;
@service modal;
editorComponent = TextareaEditor;
textManipulation;
ready = false;
@ -254,8 +76,6 @@ export default class DEditor extends Component {
},
};
_itsatrap = null;
@computed("formTemplateIds")
get selectedFormTemplateId() {
if (this._selectedFormTemplateId) {
@ -292,7 +112,7 @@ export default class DEditor extends Component {
this.set("ready", true);
if (this.autofocus) {
this._textarea.focus();
this.textManipulation.focus();
}
}
@ -307,28 +127,21 @@ export default class DEditor extends Component {
this._previewMutationObserver = this._disablePreviewTabIndex();
this._textarea = this.element.querySelector("textarea.d-editor-input");
this._$textarea = $(this._textarea);
// disable clicking on links in the preview
this.element
.querySelector(".d-editor-preview")
.addEventListener("click", this._handlePreviewLinkClick);
``;
}
this.set(
"textManipulation",
new TextareaTextManipulation(getOwner(this), {
markdownOptions: this.markdownOptions,
textarea: this._textarea,
})
);
get keymap() {
const keymap = {};
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");
Object.keys(shortcuts).forEach((sc) => {
const button = shortcuts[sc];
this._itsatrap.bind(sc, () => {
keymap[sc] = () => {
const customAction = shortcuts[sc].shortcutAction;
if (customAction) {
@ -338,7 +151,7 @@ export default class DEditor extends Component {
button.action(button);
}
return false;
});
};
});
if (this.popupMenuOptions && this.onPopupMenuAction) {
@ -346,99 +159,20 @@ export default class DEditor extends Component {
if (popupButton.shortcut && popupButton.condition) {
const shortcut =
`${PLATFORM_KEY_MODIFIER}+${popupButton.shortcut}`.toLowerCase();
this._itsatrap.bind(shortcut, () => {
keymap[shortcut] = () => {
this.onPopupMenuAction(popupButton, this.newToolbarEvent());
return false;
});
};
}
});
}
this._itsatrap.bind("tab", () =>
this.textManipulation.indentSelection("right")
);
this._itsatrap.bind("shift+tab", () =>
this.textManipulation.indentSelection("left")
);
this._itsatrap.bind(`${PLATFORM_KEY_MODIFIER}+shift+.`, () =>
this.send("insertCurrentTime")
);
keymap["tab"] = () => this.textManipulation.indentSelection("right");
keymap["shift+tab"] = () => this.textManipulation.indentSelection("left");
keymap[`${PLATFORM_KEY_MODIFIER}+shift+.`] = () =>
this.send("insertCurrentTime");
// 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._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;
return keymap;
}
@bind
@ -471,55 +205,12 @@ export default class DEditor extends Component {
@on("willDestroyElement")
_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
.querySelector(".d-editor-preview")
?.removeEventListener("click", this._handlePreviewLinkClick);
this._previewMutationObserver?.disconnect();
this.element.removeEventListener("paste", this.textManipulation.paste);
this._cachedCookFunction = null;
}
@ -639,9 +330,9 @@ export default class DEditor extends Component {
}
_applyHashtagAutocomplete() {
setupHashtagAutocomplete(
this.textManipulation.autocomplete(
hashtagAutocompleteOptions(
this.site.hashtag_configurations["topic-composer"],
this._$textarea,
this.siteSettings,
{
afterComplete: (value) => {
@ -653,15 +344,16 @@ export default class DEditor extends Component {
);
},
}
)
);
}
_applyEmojiAutocomplete($textarea) {
_applyEmojiAutocomplete() {
if (!this.siteSettings.enable_emoji) {
return;
}
$textarea.autocomplete({
this.textManipulation.autocomplete({
template: findRawTemplate("emoji-selector-autocomplete"),
key: ":",
afterComplete: (text) => {
@ -689,7 +381,7 @@ export default class DEditor extends Component {
this.emojiStore.track(v.code);
return `${v.code}:`;
} else {
$textarea.autocomplete({ cancel: true });
this.textManipulation.autocomplete({ cancel: true });
this.set("emojiPickerIsActive", true);
this.set("emojiFilter", v.term);
@ -777,8 +469,43 @@ export default class DEditor extends Component {
});
},
triggerRule: async (textarea) =>
!(await inCodeBlock(textarea.value, caretPosition(textarea))),
triggerRule: async () => !(await this.textManipulation.inCodeBlock()),
});
}
_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);
}
_toggleDirection() {
let currentDir = this._$textarea.attr("dir")
? this._$textarea.attr("dir")
: siteDir(),
newDir = currentDir === "ltr" ? "rtl" : "ltr";
this._$textarea.attr("dir", newDir).focus();
}
@action
rovingButtonBar(event) {
let target = event.target;
@ -884,7 +602,7 @@ export default class DEditor extends Component {
formatCode: (...args) => this.send("formatCode", args),
addText: (text) => this.textManipulation.addText(selected, text),
getText: () => this.value,
toggleDirection: () => this._toggleDirection(),
toggleDirection: () => this.textManipulation.toggleDirection(),
replaceText: (oldVal, newVal, opts) =>
this.textManipulation.replaceText(oldVal, newVal, opts),
};
@ -1009,6 +727,77 @@ export default class DEditor extends Component {
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() {
const observer = new MutationObserver(function () {
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(
contextualHashtagConfiguration,
$textArea,
$textarea,
siteSettings,
autocompleteOptions = {}
) {
_setup(
$textarea.autocomplete(
hashtagAutocompleteOptions(
contextualHashtagConfiguration,
$textArea,
siteSettings,
autocompleteOptions
)
);
}
@ -53,13 +54,12 @@ export async function hashtagTriggerRule(textarea) {
return !(await inCodeBlock(textarea.value, caretPosition(textarea)));
}
function _setup(
export function hashtagAutocompleteOptions(
contextualHashtagConfiguration,
$textArea,
siteSettings,
autocompleteOptions
) {
$textArea.autocomplete({
return {
template: findRawTemplate("hashtag-autocomplete"),
key: "#",
afterComplete: autocompleteOptions.afterComplete,
@ -75,7 +75,7 @@ function _setup(
},
triggerRule: async (textarea, opts) =>
await hashtagTriggerRule(textarea, opts),
});
};
}
let searchCache = {};

View File

@ -1,14 +1,16 @@
import { action } from "@ember/object";
import { setOwner } from "@ember/owner";
import { next, schedule } from "@ember/runloop";
import { service } from "@ember/service";
import { isEmpty } from "@ember/utils";
import $ from "jquery";
import { generateLinkifyFunction } from "discourse/lib/text";
import { siteDir } from "discourse/lib/text-direction";
import toMarkdown from "discourse/lib/to-markdown";
import {
caretPosition,
clipboardHelpers,
determinePostReplaceSelection,
inCodeBlock,
} from "discourse/lib/utilities";
import { isTesting } from "discourse-common/config/environment";
import { bind } from "discourse-common/utils/decorators";
@ -43,12 +45,14 @@ export default class TextareaTextManipulation {
eventPrefix;
textarea;
$textarea;
constructor(owner, { markdownOptions, textarea, eventPrefix = "composer" }) {
setOwner(this, owner);
this.eventPrefix = eventPrefix;
this.textarea = textarea;
this.$textarea = $(textarea);
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.
@ -66,6 +70,10 @@ export default class TextareaTextManipulation {
this.textarea?.focus();
}
focus() {
this.textarea.focus();
}
insertBlock(text) {
this._addBlock(this.getSelected(), text);
}
@ -400,7 +408,7 @@ export default class TextareaTextManipulation {
const selected = this.getSelected(null, { lineVal: true });
const { pre, value: selectedValue, lineVal } = selected;
const isInlinePasting = pre.match(/[^\n]$/);
const isCodeBlock = this.isInsideCodeFence(pre);
const isCodeBlock = this.#isAfterStartedCodeFence(pre);
if (
plainText &&
@ -515,7 +523,10 @@ export default class TextareaTextManipulation {
.join("\n");
}
@bind
#isAfterStartedCodeFence(beforeText) {
return this.isInside(beforeText, /(^|\n)```/g);
}
maybeContinueList() {
const offset = caretPosition(this.textarea);
const text = this.value;
@ -528,7 +539,7 @@ export default class TextareaTextManipulation {
return;
}
if (this.isInsideCodeFence(text.substring(0, offset - 1))) {
if (this.#isAfterStartedCodeFence(text.substring(0, offset - 1))) {
return;
}
@ -624,7 +635,6 @@ export default class TextareaTextManipulation {
}
}
@bind
indentSelection(direction) {
if (![INDENT_DIRECTION_LEFT, INDENT_DIRECTION_RIGHT].includes(direction)) {
return;
@ -699,7 +709,7 @@ export default class TextareaTextManipulation {
}
}
@action
@bind
emojiSelected(code) {
let selected = this.getSelected();
const captures = selected.pre.match(/\B:(\w*)$/);
@ -720,7 +730,23 @@ export default class TextareaTextManipulation {
}
}
isInsideCodeFence(beforeText) {
return this.isInside(beforeText, /(^|\n)```/g);
async inCodeBlock() {
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);
}
}