mirror of
https://github.com/discourse/discourse.git
synced 2024-11-24 18:00:39 -06:00
DEV: refactor textarea from d-editor (#29411)
Refactors the DEditor component making it textarea-agnostic.
This commit is contained in:
parent
6459ab9320
commit
b061fd9cc2
@ -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
|
||||
|
@ -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>
|
||||
}
|
@ -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}} />
|
||||
|
@ -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,29 +330,30 @@ export default class DEditor extends Component {
|
||||
}
|
||||
|
||||
_applyHashtagAutocomplete() {
|
||||
setupHashtagAutocomplete(
|
||||
this.site.hashtag_configurations["topic-composer"],
|
||||
this._$textarea,
|
||||
this.siteSettings,
|
||||
{
|
||||
afterComplete: (value) => {
|
||||
this.set("value", value);
|
||||
schedule(
|
||||
"afterRender",
|
||||
this.textManipulation,
|
||||
this.textManipulation.blurAndFocus
|
||||
);
|
||||
},
|
||||
}
|
||||
this.textManipulation.autocomplete(
|
||||
hashtagAutocompleteOptions(
|
||||
this.site.hashtag_configurations["topic-composer"],
|
||||
this.siteSettings,
|
||||
{
|
||||
afterComplete: (value) => {
|
||||
this.set("value", value);
|
||||
schedule(
|
||||
"afterRender",
|
||||
this.textManipulation,
|
||||
this.textManipulation.blurAndFocus
|
||||
);
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
_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) => {
|
||||
|
179
app/assets/javascripts/discourse/app/lib/composer/toolbar.js
Normal file
179
app/assets/javascripts/discourse/app/lib/composer/toolbar.js
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
@ -37,15 +37,16 @@ import { findRawTemplate } from "discourse-common/lib/raw-templates";
|
||||
**/
|
||||
export function setupHashtagAutocomplete(
|
||||
contextualHashtagConfiguration,
|
||||
$textArea,
|
||||
$textarea,
|
||||
siteSettings,
|
||||
autocompleteOptions = {}
|
||||
) {
|
||||
_setup(
|
||||
contextualHashtagConfiguration,
|
||||
$textArea,
|
||||
siteSettings,
|
||||
autocompleteOptions
|
||||
$textarea.autocomplete(
|
||||
hashtagAutocompleteOptions(
|
||||
contextualHashtagConfiguration,
|
||||
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 = {};
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user