This repository has been archived on 2023-03-18. You can view files and clone it, but cannot push or open issues or pull requests.
osr-discourse-src/app/assets/javascripts/discourse/app/components/d-editor.js
Jarek Radosz 1a5c6f7632
FIX: Setting emoji filter from initial autocomplete (#16063)
…has regressed at some point
2022-02-28 15:01:26 +01:00

786 lines
21 KiB
JavaScript

import { ajax } from "discourse/lib/ajax";
import {
caretPosition,
inCodeBlock,
translateModKey,
} from "discourse/lib/utilities";
import discourseComputed, {
bind,
observes,
on,
} from "discourse-common/utils/decorators";
import { emojiSearch, isSkinTonableEmoji } from "pretty-text/emoji";
import { emojiUrlFor, generateCookFunction } from "discourse/lib/text";
import { schedule, scheduleOnce } from "@ember/runloop";
import Component from "@ember/component";
import I18n from "I18n";
import ItsATrap from "@discourse/itsatrap";
import { Promise } from "rsvp";
import { SKIP } from "discourse/lib/autocomplete";
import { categoryHashtagTriggerRule } from "discourse/lib/category-hashtags";
import deprecated from "discourse-common/lib/deprecated";
import discourseDebounce from "discourse-common/lib/debounce";
import { findRawTemplate } from "discourse-common/lib/raw-templates";
import { getRegister } from "discourse-common/lib/get-owner";
import { isTesting } from "discourse-common/config/environment";
import { linkSeenHashtags } from "discourse/lib/link-hashtags";
import { linkSeenMentions } from "discourse/lib/link-mentions";
import { loadOneboxes } from "discourse/lib/load-oneboxes";
import loadScript from "discourse/lib/load-script";
import { resolveCachedShortUrls } from "pretty-text/upload-short-url";
import { search as searchCategoryTag } from "discourse/lib/category-tag-search";
import { inject as service } from "@ember/service";
import showModal from "discourse/lib/show-modal";
import { siteDir } from "discourse/lib/text-direction";
import { translations } from "pretty-text/emoji/data";
import { wantsNewWindow } from "discourse/lib/intercept-click";
import { action } from "@ember/object";
import TextareaTextManipulation, {
getHead,
} from "discourse/mixins/textarea-text-manipulation";
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 { site, siteSettings } = opts;
this.shortcuts = {};
this.context = null;
this.groups = [
{ group: "fontStyles", buttons: [] },
{ group: "insertions", buttons: [] },
{ group: "extras", buttons: [] },
];
this.addButton({
id: "bold",
group: "fontStyles",
icon: "bold",
label: getButtonLabel("composer.bold_label", "B"),
shortcut: "B",
preventFocus: true,
trimLeading: true,
perform: (e) => e.applySurround("**", "**", "bold_text"),
});
this.addButton({
id: "italic",
group: "fontStyles",
icon: "italic",
label: getButtonLabel("composer.italic_label", "I"),
shortcut: "I",
preventFocus: true,
trimLeading: true,
perform: (e) => e.applySurround("*", "*", "italic_text"),
});
if (opts.showLink) {
this.addButton({
id: "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,
}),
});
this.addButton({
id: "code",
group: "insertions",
shortcut: "E",
preventFocus: true,
trimLeading: true,
action: (...args) => this.context.send("formatCode", args),
});
if (!site.mobileView) {
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: "exchange-alt",
shortcut: "Shift+6",
title: "composer.toggle_direction",
preventFocus: true,
perform: (e) => e.toggleDirection(),
});
}
this.groups[this.groups.length - 1].lastGroup = true;
}
addButton(button) {
const g = this.groups.findBy("group", button.group);
if (!g) {
throw new Error(`Couldn't find toolbar group ${button.group}`);
}
const createdButton = {
id: button.id,
tabindex: button.tabindex || "-1",
className: button.className || button.id,
label: button.label,
icon: button.label ? null : button.icon || button.id,
action: button.action || ((a) => this.context.send("toolbarButton", a)),
perform: button.perform || function () {},
trimLeading: button.trimLeading,
popupMenu: button.popupMenu || false,
preventFocus: button.preventFocus || false,
};
if (button.sendAction) {
createdButton.sendAction = button.sendAction;
}
const title = I18n.t(button.title || `composer.${button.id}_title`);
if (button.shortcut) {
const mac = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
const mod = mac ? "Meta" : "Ctrl";
const shortcutTitle = `${translateModKey(mod + "+")}${translateModKey(
button.shortcut
)}`;
createdButton.title = `${title} (${shortcutTitle})`;
this.shortcuts[`${mod}+${button.shortcut}`.toLowerCase()] = createdButton;
} else {
createdButton.title = title;
}
if (button.unshift) {
g.buttons.unshift(createdButton);
} else {
g.buttons.push(createdButton);
}
}
}
export function addToolbarCallback(func) {
_createCallbacks.push(func);
}
export function clearToolbarCallbacks() {
_createCallbacks = [];
}
export function onToolbarCreate(func) {
deprecated("`onToolbarCreate` is deprecated, use the plugin api instead.");
addToolbarCallback(func);
}
export default Component.extend(TextareaTextManipulation, {
classNames: ["d-editor"],
ready: false,
lastSel: null,
_itsatrap: null,
showLink: true,
emojiPickerIsActive: false,
emojiFilter: "",
emojiStore: service("emoji-store"),
isEditorFocused: false,
processPreview: true,
composerFocusSelector: "#reply-control .d-editor-input",
@discourseComputed("placeholder")
placeholderTranslated(placeholder) {
if (placeholder) {
return I18n.t(placeholder);
}
return null;
},
_readyNow() {
this.set("ready", true);
if (this.autofocus) {
this._textarea.focus();
}
},
init() {
this._super(...arguments);
this.register = getRegister(this);
},
didInsertElement() {
this._super(...arguments);
this._previewMutationObserver = this._disablePreviewTabIndex();
this._textarea = this.element.querySelector("textarea.d-editor-input");
this._$textarea = $(this._textarea);
this._applyEmojiAutocomplete(this._$textarea);
this._applyCategoryHashtagAutocomplete(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, () => {
button.action(button);
return false;
});
});
this._itsatrap.bind("tab", () => this.indentSelection("right"));
this._itsatrap.bind("shift+tab", () => this.indentSelection("left"));
// 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, "insertBlock");
this.appEvents.on("composer:insert-text", this, "insertText");
this.appEvents.on("composer:replace-text", this, "replaceText");
this.appEvents.on(
"composer:indent-selected-text",
this,
"indentSelection"
);
}
if (isTesting()) {
this.element.addEventListener("paste", this.paste);
}
},
@bind
_handlePreviewLinkClick(event) {
if (wantsNewWindow(event)) {
return;
}
if (event.target.tagName === "A") {
if (event.target.classList.contains("mention")) {
this.appEvents.trigger(
"click.discourse-preview-user-card-mention",
$(event.target)
);
}
if (event.target.classList.contains("mention-group")) {
this.appEvents.trigger(
"click.discourse-preview-group-card-mention-group",
$(event.target)
);
}
event.preventDefault();
return false;
}
},
@on("willDestroyElement")
_shutDown() {
if (this.composerEvents) {
this.appEvents.off("composer:insert-block", this, "insertBlock");
this.appEvents.off("composer:insert-text", this, "insertText");
this.appEvents.off("composer:replace-text", this, "replaceText");
this.appEvents.off(
"composer:indent-selected-text",
this,
"indentSelection"
);
}
this._itsatrap?.destroy();
this._itsatrap = null;
this.element
.querySelector(".d-editor-preview")
?.removeEventListener("click", this._handlePreviewLinkClick);
this._previewMutationObserver?.disconnect();
if (isTesting()) {
this.element.removeEventListener("paste", this.paste);
}
this._cachedCookFunction = null;
},
@discourseComputed()
toolbar() {
const toolbar = new Toolbar(
this.getProperties("site", "siteSettings", "showLink")
);
toolbar.context = this;
_createCallbacks.forEach((cb) => cb(toolbar));
if (this.extraButtons) {
this.extraButtons(toolbar);
}
return toolbar;
},
cachedCookAsync(text) {
if (this._cachedCookFunction) {
return Promise.resolve(this._cachedCookFunction(text));
}
const markdownOptions = this.markdownOptions || {};
return generateCookFunction(markdownOptions).then((cook) => {
this._cachedCookFunction = cook;
return cook(text);
});
},
_updatePreview() {
if (this._state !== "inDOM" || !this.processPreview) {
return;
}
const value = this.value;
this.cachedCookAsync(value).then((cooked) => {
if (this.isDestroyed) {
return;
}
if (this.preview === cooked) {
return;
}
this.set("preview", cooked);
let previewPromise = Promise.resolve();
if (this.siteSettings.enable_diffhtml_preview) {
const cookedElement = document.createElement("div");
cookedElement.innerHTML = cooked;
linkSeenHashtags(cookedElement);
linkSeenMentions(cookedElement, this.siteSettings);
resolveCachedShortUrls(this.siteSettings, cookedElement);
loadOneboxes(
cookedElement,
ajax,
null,
null,
this.siteSettings.max_oneboxes_per_post,
false,
true
);
previewPromise = loadScript("/javascripts/diffhtml.min.js").then(() => {
window.diff.innerHTML(
this.element.querySelector(".d-editor-preview"),
cookedElement.innerHTML
);
});
}
previewPromise.then(() => {
schedule("afterRender", () => {
if (this._state !== "inDOM" || !this.element) {
return;
}
const preview = this.element.querySelector(".d-editor-preview");
if (!preview) {
return;
}
if (this.previewUpdated) {
this.previewUpdated(preview);
}
});
});
});
},
@observes("ready", "value", "processPreview")
_watchForChanges() {
if (!this.ready) {
return;
}
// Debouncing in test mode is complicated
if (isTesting()) {
this._updatePreview();
} else {
discourseDebounce(this, this._updatePreview, 30);
}
},
_applyCategoryHashtagAutocomplete() {
const siteSettings = this.siteSettings;
this._$textarea.autocomplete({
template: findRawTemplate("category-tag-autocomplete"),
key: "#",
afterComplete: (value) => {
this.set("value", value);
schedule("afterRender", this, this.focusTextArea);
},
transformComplete: (obj) => {
return obj.text;
},
dataSource: (term) => {
if (term.match(/\s/)) {
return null;
}
return searchCategoryTag(term, siteSettings);
},
triggerRule: (textarea, opts) => {
return categoryHashtagTriggerRule(textarea, opts);
},
});
},
_applyEmojiAutocomplete($textarea) {
if (!this.siteSettings.enable_emoji) {
return;
}
$textarea.autocomplete({
template: findRawTemplate("emoji-selector-autocomplete"),
key: ":",
afterComplete: (text) => {
this.set("value", text);
schedule("afterRender", this, this.focusTextArea);
},
onKeyUp: (text, cp) => {
if (inCodeBlock(text, cp)) {
return false;
}
const matches = /(?:^|[\s.\?,@\/#!%&*;:\[\]{}=\-_()])(:(?!:).?[\w-]*:?(?!:)(?:t\d?)?:?) ?$/gi.exec(
text.substring(0, cp)
);
if (matches && matches[1]) {
return [matches[1]];
}
},
transformComplete: (v) => {
if (v.code) {
this.emojiStore.track(v.code);
return `${v.code}:`;
} else {
$textarea.autocomplete({ cancel: true });
this.set("emojiPickerIsActive", true);
this.set("emojiFilter", v.term);
return "";
}
},
dataSource: (term) => {
return new Promise((resolve) => {
const full = `:${term}`;
term = term.toLowerCase();
if (term.length < this.siteSettings.emoji_autocomplete_min_chars) {
return resolve(SKIP);
}
if (term === "") {
if (this.emojiStore.favorites.length) {
return resolve(this.emojiStore.favorites.slice(0, 5));
} else {
return resolve([
"slight_smile",
"smile",
"wink",
"sunny",
"blush",
]);
}
}
// note this will only work for emojis starting with :
// eg: :-)
const allTranslations = Object.assign(
{},
translations,
this.getWithDefault("site.custom_emoji_translation", {})
);
if (allTranslations[full]) {
return resolve([allTranslations[full]]);
}
const match = term.match(/^:?(.*?):t([2-6])?$/);
if (match) {
const name = match[1];
const scale = match[2];
if (isSkinTonableEmoji(name)) {
if (scale) {
return resolve([`${name}:t${scale}`]);
} else {
return resolve([2, 3, 4, 5, 6].map((x) => `${name}:t${x}`));
}
}
}
const options = emojiSearch(term, {
maxResults: 5,
diversity: this.emojiStore.diversity,
});
return resolve(options);
})
.then((list) =>
list.map((code) => {
return { code, src: emojiUrlFor(code) };
})
)
.then((list) => {
if (list.length) {
list.push({ label: I18n.t("composer.more_emoji"), term });
}
return list;
});
},
triggerRule: (textarea) =>
!inCodeBlock(textarea.value, caretPosition(textarea)),
});
},
_applyList(sel, head, exampleKey, opts) {
if (sel.value.indexOf("\n") !== -1) {
this.applySurround(sel, head, "", exampleKey, opts);
} else {
const [hval, hlen] = getHead(head);
if (sel.start === sel.end) {
sel.value = I18n.t(`composer.${exampleKey}`);
}
const trimmedPre = sel.pre.trim();
const number =
sel.value.indexOf(hval) === 0
? sel.value.slice(hlen)
: `${hval}${sel.value}`;
const preLines = trimmedPre.length ? `${trimmedPre}\n\n` : "";
const trimmedPost = sel.post.trim();
const post = trimmedPost.length ? `\n\n${trimmedPost}` : trimmedPost;
this.set("value", `${preLines}${number}${post}`);
this.selectText(preLines.length, number.length);
}
},
_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;
let siblingFinder;
if (event.code === "ArrowRight") {
siblingFinder = "nextElementSibling";
} else if (event.code === "ArrowLeft") {
siblingFinder = "previousElementSibling";
} else {
return true;
}
while (
target.parentNode &&
!target.parentNode.classList.contains("d-editor-button-bar")
) {
target = target.parentNode;
}
let focusable = target[siblingFinder];
if (focusable) {
while (
(focusable.tagName !== "BUTTON" &&
!focusable.classList.contains("select-kit")) ||
focusable.classList.contains("hidden")
) {
focusable = focusable[siblingFinder];
}
if (focusable?.tagName === "DETAILS") {
focusable = focusable.querySelector("summary");
}
focusable?.focus();
}
return true;
},
actions: {
emoji() {
if (this.disabled) {
return;
}
this.set("emojiPickerIsActive", !this.emojiPickerIsActive);
},
toolbarButton(button) {
if (this.disabled) {
return;
}
const selected = this.getSelected(button.trimLeading);
const toolbarEvent = {
selected,
selectText: (from, length) =>
this.selectText(from, length, { scroll: false }),
applySurround: (head, tail, exampleKey, opts) =>
this.applySurround(selected, head, tail, exampleKey, opts),
applyList: (head, exampleKey, opts) =>
this._applyList(selected, head, exampleKey, opts),
addText: (text) => this.addText(selected, text),
getText: () => this.value,
toggleDirection: () => this._toggleDirection(),
};
if (button.sendAction) {
return button.sendAction(toolbarEvent);
} else {
button.perform(toolbarEvent);
}
},
showLinkModal(toolbarEvent) {
if (this.disabled) {
return;
}
let linkText = "";
this._lastSel = toolbarEvent.selected;
if (this._lastSel) {
linkText = this._lastSel.value;
}
showModal("insert-hyperlink").setProperties({
linkText,
toolbarEvent,
});
},
formatCode() {
if (this.disabled) {
return;
}
const sel = this.getSelected("", { lineVal: true });
const selValue = sel.value;
const hasNewLine = selValue.indexOf("\n") !== -1;
const isBlankLine = sel.lineVal.trim().length === 0;
const isFourSpacesIndent =
this.siteSettings.code_formatting_style === FOUR_SPACES_INDENT;
if (!hasNewLine) {
if (selValue.length === 0 && isBlankLine) {
if (isFourSpacesIndent) {
const example = I18n.t(`composer.code_text`);
this.set("value", `${sel.pre} ${example}${sel.post}`);
return this.selectText(sel.pre.length + 4, example.length);
} else {
return this.applySurround(sel, "```\n", "\n```", "paste_code_text");
}
} else {
return this.applySurround(sel, "`", "`", "code_title");
}
} else {
if (isFourSpacesIndent) {
return this.applySurround(sel, " ", "", "code_text");
} else {
const preNewline = sel.pre[-1] !== "\n" && sel.pre !== "" ? "\n" : "";
const postNewline = sel.post[0] !== "\n" ? "\n" : "";
return this.addText(
sel,
`${preNewline}\`\`\`\n${sel.value}\n\`\`\`${postNewline}`
);
}
}
},
focusIn() {
this.set("isEditorFocused", true);
},
focusOut() {
this.set("isEditorFocused", false);
},
},
_disablePreviewTabIndex() {
const observer = new MutationObserver(function () {
document.querySelectorAll(".d-editor-preview a").forEach((anchor) => {
anchor.setAttribute("tabindex", "-1");
});
});
observer.observe(document.querySelector(".d-editor-preview"), {
childList: true,
subtree: true,
attributes: false,
characterData: true,
});
return observer;
},
});