mirror of
https://github.com/discourse/discourse.git
synced 2024-11-25 02:11:08 -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,
|
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
|
||||||
|
@ -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>
|
</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}} />
|
||||||
|
@ -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) => {
|
||||||
|
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(
|
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 = {};
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user