This is a bit hacky but it's much more simple and reliable than many other solutions and doesnt involve having to manage some variable state.
1012 lines
27 KiB
JavaScript
1012 lines
27 KiB
JavaScript
/*global Mousetrap:true */
|
|
import {
|
|
default as computed,
|
|
on,
|
|
observes
|
|
} from "ember-addons/ember-computed-decorators";
|
|
import { categoryHashtagTriggerRule } from "discourse/lib/category-hashtags";
|
|
import { search as searchCategoryTag } from "discourse/lib/category-tag-search";
|
|
import { cookAsync } from "discourse/lib/text";
|
|
import { getRegister } from "discourse-common/lib/get-owner";
|
|
import { findRawTemplate } from "discourse/lib/raw-templates";
|
|
import { siteDir } from "discourse/lib/text-direction";
|
|
import {
|
|
determinePostReplaceSelection,
|
|
clipboardData,
|
|
safariHacksDisabled
|
|
} from "discourse/lib/utilities";
|
|
import toMarkdown from "discourse/lib/to-markdown";
|
|
import deprecated from "discourse-common/lib/deprecated";
|
|
import { wantsNewWindow } from "discourse/lib/intercept-click";
|
|
import { translations } from "pretty-text/emoji/data";
|
|
import { emojiSearch, isSkinTonableEmoji } from "pretty-text/emoji";
|
|
import { emojiUrlFor } from "discourse/lib/text";
|
|
|
|
// Our head can be a static string or a function that returns a string
|
|
// based on input (like for numbered lists).
|
|
function getHead(head, prev) {
|
|
if (typeof head === "string") {
|
|
return [head, head.length];
|
|
} else {
|
|
return getHead(head(prev));
|
|
}
|
|
}
|
|
|
|
function getButtonLabel(labelKey, defaultLabel) {
|
|
// use the Font Awesome icon if the label matches the default
|
|
return I18n.t(labelKey) === defaultLabel ? null : labelKey;
|
|
}
|
|
|
|
const OP = {
|
|
NONE: 0,
|
|
REMOVED: 1,
|
|
ADDED: 2
|
|
};
|
|
|
|
const FOUR_SPACES_INDENT = "4-spaces-indent";
|
|
|
|
const _createCallbacks = [];
|
|
|
|
const isInside = (text, regex) => {
|
|
const matches = text.match(regex);
|
|
return matches && matches.length % 2;
|
|
};
|
|
|
|
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({
|
|
trimLeading: true,
|
|
id: "bold",
|
|
group: "fontStyles",
|
|
icon: "bold",
|
|
label: getButtonLabel("composer.bold_label", "B"),
|
|
shortcut: "B",
|
|
perform: e => e.applySurround("**", "**", "bold_text")
|
|
});
|
|
|
|
this.addButton({
|
|
trimLeading: true,
|
|
id: "italic",
|
|
group: "fontStyles",
|
|
icon: "italic",
|
|
label: getButtonLabel("composer.italic_label", "I"),
|
|
shortcut: "I",
|
|
perform: e => e.applySurround("*", "*", "italic_text")
|
|
});
|
|
|
|
if (opts.showLink) {
|
|
this.addButton({
|
|
id: "link",
|
|
group: "insertions",
|
|
shortcut: "K",
|
|
action: (...args) => this.context.send("showLinkModal", args)
|
|
});
|
|
}
|
|
|
|
this.addButton({
|
|
id: "quote",
|
|
group: "insertions",
|
|
icon: "quote-right",
|
|
shortcut: "Shift+9",
|
|
perform: e =>
|
|
e.applyList("> ", "blockquote_text", {
|
|
applyEmptyLines: true,
|
|
multiline: true
|
|
})
|
|
});
|
|
|
|
this.addButton({
|
|
id: "code",
|
|
group: "insertions",
|
|
shortcut: "Shift+C",
|
|
action: (...args) => this.context.send("formatCode", args)
|
|
});
|
|
|
|
this.addButton({
|
|
id: "bullet",
|
|
group: "extras",
|
|
icon: "list-ul",
|
|
shortcut: "Shift+8",
|
|
title: "composer.ulist_title",
|
|
perform: e => e.applyList("* ", "list_item")
|
|
});
|
|
|
|
this.addButton({
|
|
id: "list",
|
|
group: "extras",
|
|
icon: "list-ol",
|
|
shortcut: "Shift+7",
|
|
title: "composer.olist_title",
|
|
perform: e =>
|
|
e.applyList(i => (!i ? "1. " : `${parseInt(i) + 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",
|
|
perform: e => e.toggleDirection()
|
|
});
|
|
}
|
|
|
|
if (site.mobileView) {
|
|
this.groups.push({ group: "mobileExtras", buttons: [] });
|
|
}
|
|
|
|
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,
|
|
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
|
|
};
|
|
|
|
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";
|
|
var shortcutTitle = `${mod}+${button.shortcut}`;
|
|
|
|
// Mac users are used to glyphs for shortcut keys
|
|
if (mac) {
|
|
shortcutTitle = shortcutTitle
|
|
.replace("Shift", "\u21E7")
|
|
.replace("Meta", "\u2318")
|
|
.replace("Alt", "\u2325")
|
|
.replace(/\+/g, "");
|
|
} else {
|
|
shortcutTitle = shortcutTitle
|
|
.replace("Shift", I18n.t("shortcut_modifier_key.shift"))
|
|
.replace("Ctrl", I18n.t("shortcut_modifier_key.ctrl"))
|
|
.replace("Alt", I18n.t("shortcut_modifier_key.alt"));
|
|
}
|
|
|
|
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 onToolbarCreate(func) {
|
|
deprecated("`onToolbarCreate` is deprecated, use the plugin api instead.");
|
|
addToolbarCallback(func);
|
|
}
|
|
|
|
export default Ember.Component.extend({
|
|
classNames: ["d-editor"],
|
|
ready: false,
|
|
insertLinkHidden: true,
|
|
linkUrl: "",
|
|
linkText: "",
|
|
lastSel: null,
|
|
_mouseTrap: null,
|
|
showLink: true,
|
|
emojiPickerIsActive: false,
|
|
|
|
@computed("placeholder")
|
|
placeholderTranslated(placeholder) {
|
|
if (placeholder) return I18n.t(placeholder);
|
|
return null;
|
|
},
|
|
|
|
_readyNow() {
|
|
this.set("ready", true);
|
|
|
|
if (this.get("autofocus")) {
|
|
this.$("textarea").focus();
|
|
}
|
|
},
|
|
|
|
init() {
|
|
this._super(...arguments);
|
|
|
|
this.register = getRegister(this);
|
|
},
|
|
|
|
didInsertElement() {
|
|
this._super(...arguments);
|
|
|
|
const $editorInput = this.$(".d-editor-input");
|
|
this._applyEmojiAutocomplete($editorInput);
|
|
this._applyCategoryHashtagAutocomplete($editorInput);
|
|
|
|
Ember.run.scheduleOnce("afterRender", this, this._readyNow);
|
|
|
|
const mouseTrap = Mousetrap(this.$(".d-editor-input")[0]);
|
|
const shortcuts = this.get("toolbar.shortcuts");
|
|
|
|
Object.keys(shortcuts).forEach(sc => {
|
|
const button = shortcuts[sc];
|
|
mouseTrap.bind(sc, () => {
|
|
button.action(button);
|
|
return false;
|
|
});
|
|
});
|
|
|
|
// disable clicking on links in the preview
|
|
this.$(".d-editor-preview").on("click.preview", e => {
|
|
if (wantsNewWindow(e)) {
|
|
return;
|
|
}
|
|
const $target = $(e.target);
|
|
if ($target.is("a.mention")) {
|
|
this.appEvents.trigger(
|
|
"click.discourse-preview-user-card-mention",
|
|
$target
|
|
);
|
|
}
|
|
if ($target.is("a.mention-group")) {
|
|
this.appEvents.trigger(
|
|
"click.discourse-preview-group-card-mention-group",
|
|
$target
|
|
);
|
|
}
|
|
if ($target.is("a")) {
|
|
e.preventDefault();
|
|
return false;
|
|
}
|
|
});
|
|
|
|
if (this.get("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._mouseTrap = mouseTrap;
|
|
},
|
|
|
|
_insertBlock(text) {
|
|
this._addBlock(this._getSelected(), text);
|
|
},
|
|
|
|
_insertText(text, options) {
|
|
this._addText(this._getSelected(), text, options);
|
|
},
|
|
|
|
@on("willDestroyElement")
|
|
_shutDown() {
|
|
if (this.get("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");
|
|
}
|
|
|
|
const mouseTrap = this._mouseTrap;
|
|
Object.keys(this.get("toolbar.shortcuts")).forEach(sc =>
|
|
mouseTrap.unbind(sc)
|
|
);
|
|
this.$(".d-editor-preview").off("click.preview");
|
|
},
|
|
|
|
@computed
|
|
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;
|
|
},
|
|
|
|
_updatePreview() {
|
|
if (this._state !== "inDOM") {
|
|
return;
|
|
}
|
|
|
|
const value = this.get("value");
|
|
const markdownOptions = this.get("markdownOptions") || {};
|
|
|
|
cookAsync(value, markdownOptions).then(cooked => {
|
|
if (this.get("isDestroyed")) {
|
|
return;
|
|
}
|
|
this.set("preview", cooked);
|
|
Ember.run.scheduleOnce("afterRender", () => {
|
|
if (this._state !== "inDOM") {
|
|
return;
|
|
}
|
|
const $preview = this.$(".d-editor-preview");
|
|
if ($preview.length === 0) return;
|
|
|
|
if (this.previewUpdated) {
|
|
this.previewUpdated($preview);
|
|
}
|
|
});
|
|
});
|
|
},
|
|
|
|
@observes("ready", "value")
|
|
_watchForChanges() {
|
|
if (!this.get("ready")) {
|
|
return;
|
|
}
|
|
|
|
// Debouncing in test mode is complicated
|
|
if (Ember.testing) {
|
|
this._updatePreview();
|
|
} else {
|
|
Ember.run.debounce(this, this._updatePreview, 30);
|
|
}
|
|
},
|
|
|
|
_applyCategoryHashtagAutocomplete() {
|
|
const siteSettings = this.siteSettings;
|
|
|
|
this.$(".d-editor-input").autocomplete({
|
|
template: findRawTemplate("category-tag-autocomplete"),
|
|
key: "#",
|
|
afterComplete: () => 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($editorInput) {
|
|
if (!this.siteSettings.enable_emoji) {
|
|
return;
|
|
}
|
|
|
|
$editorInput.autocomplete({
|
|
template: findRawTemplate("emoji-selector-autocomplete"),
|
|
key: ":",
|
|
afterComplete: text => {
|
|
this.set("value", text);
|
|
this._focusTextArea();
|
|
},
|
|
|
|
onKeyUp: (text, cp) => {
|
|
const matches = /(?:^|[^a-z])(:(?!:).?[\w-]*:?(?!:)(?:t\d?)?:?) ?$/gi.exec(
|
|
text.substring(0, cp)
|
|
);
|
|
|
|
if (matches && matches[1]) {
|
|
return [matches[1]];
|
|
}
|
|
},
|
|
|
|
transformComplete: v => {
|
|
if (v.code) {
|
|
return `${v.code}:`;
|
|
} else {
|
|
$editorInput.autocomplete({ cancel: true });
|
|
this.setProperties({
|
|
isEditorFocused: $("textarea.d-editor-input").is(":focus"),
|
|
emojiPickerIsActive: true
|
|
});
|
|
|
|
Ember.run.schedule("afterRender", () => {
|
|
const filterInput = document.querySelector(
|
|
".emoji-picker input[name='filter']"
|
|
);
|
|
if (filterInput) {
|
|
filterInput.value = v.term;
|
|
|
|
Ember.run.later(
|
|
() => filterInput.dispatchEvent(new Event("input")),
|
|
50
|
|
);
|
|
}
|
|
});
|
|
|
|
return "";
|
|
}
|
|
},
|
|
|
|
dataSource: term => {
|
|
return new Ember.RSVP.Promise(resolve => {
|
|
const full = `:${term}`;
|
|
term = term.toLowerCase();
|
|
|
|
if (term.length < this.siteSettings.emoji_autocomplete_min_chars) {
|
|
return resolve([]);
|
|
}
|
|
|
|
if (term === "") {
|
|
return resolve(["slight_smile", "smile", "wink", "sunny", "blush"]);
|
|
}
|
|
|
|
if (translations[full]) {
|
|
return resolve([translations[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 });
|
|
|
|
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;
|
|
});
|
|
}
|
|
});
|
|
},
|
|
|
|
_getSelected(trimLeading, opts) {
|
|
if (!this.get("ready")) {
|
|
return;
|
|
}
|
|
|
|
const textarea = this.$("textarea.d-editor-input")[0];
|
|
const value = textarea.value;
|
|
let start = textarea.selectionStart;
|
|
let end = textarea.selectionEnd;
|
|
|
|
// trim trailing spaces cause **test ** would be invalid
|
|
while (end > start && /\s/.test(value.charAt(end - 1))) {
|
|
end--;
|
|
}
|
|
|
|
if (trimLeading) {
|
|
// trim leading spaces cause ** test** would be invalid
|
|
while (end > start && /\s/.test(value.charAt(start))) {
|
|
start++;
|
|
}
|
|
}
|
|
|
|
const selVal = value.substring(start, end);
|
|
const pre = value.slice(0, start);
|
|
const post = value.slice(end);
|
|
|
|
if (opts && opts.lineVal) {
|
|
const lineVal = value.split("\n")[
|
|
value.substr(0, textarea.selectionStart).split("\n").length - 1
|
|
];
|
|
return { start, end, value: selVal, pre, post, lineVal };
|
|
} else {
|
|
return { start, end, value: selVal, pre, post };
|
|
}
|
|
},
|
|
|
|
_selectText(from, length) {
|
|
Ember.run.scheduleOnce("afterRender", () => {
|
|
const $textarea = this.$("textarea.d-editor-input");
|
|
const textarea = $textarea[0];
|
|
const oldScrollPos = $textarea.scrollTop();
|
|
if (!this.capabilities.isIOS || safariHacksDisabled()) {
|
|
$textarea.focus();
|
|
}
|
|
textarea.selectionStart = from;
|
|
textarea.selectionEnd = textarea.selectionStart + length;
|
|
$textarea.scrollTop(oldScrollPos);
|
|
});
|
|
},
|
|
|
|
// perform the same operation over many lines of text
|
|
_getMultilineContents(lines, head, hval, hlen, tail, tlen, opts) {
|
|
let operation = OP.NONE;
|
|
|
|
const applyEmptyLines = opts && opts.applyEmptyLines;
|
|
|
|
return lines
|
|
.map(l => {
|
|
if (!applyEmptyLines && l.length === 0) {
|
|
return l;
|
|
}
|
|
|
|
if (
|
|
operation !== OP.ADDED &&
|
|
((l.slice(0, hlen) === hval && tlen === 0) ||
|
|
(tail.length && l.slice(-tlen) === tail))
|
|
) {
|
|
operation = OP.REMOVED;
|
|
if (tlen === 0) {
|
|
const result = l.slice(hlen);
|
|
[hval, hlen] = getHead(head, hval);
|
|
return result;
|
|
} else if (l.slice(-tlen) === tail) {
|
|
const result = l.slice(hlen, -tlen);
|
|
[hval, hlen] = getHead(head, hval);
|
|
return result;
|
|
}
|
|
} else if (operation === OP.NONE) {
|
|
operation = OP.ADDED;
|
|
} else if (operation === OP.REMOVED) {
|
|
return l;
|
|
}
|
|
|
|
const result = `${hval}${l}${tail}`;
|
|
[hval, hlen] = getHead(head, hval);
|
|
return result;
|
|
})
|
|
.join("\n");
|
|
},
|
|
|
|
_applySurround(sel, head, tail, exampleKey, opts) {
|
|
const pre = sel.pre;
|
|
const post = sel.post;
|
|
|
|
const tlen = tail.length;
|
|
if (sel.start === sel.end) {
|
|
if (tlen === 0) {
|
|
return;
|
|
}
|
|
|
|
const [hval, hlen] = getHead(head);
|
|
const example = I18n.t(`composer.${exampleKey}`);
|
|
this.set("value", `${pre}${hval}${example}${tail}${post}`);
|
|
this._selectText(pre.length + hlen, example.length);
|
|
} else if (opts && !opts.multiline) {
|
|
const [hval, hlen] = getHead(head);
|
|
|
|
if (pre.slice(-hlen) === hval && post.slice(0, tail.length) === tail) {
|
|
this.set(
|
|
"value",
|
|
`${pre.slice(0, -hlen)}${sel.value}${post.slice(tail.length)}`
|
|
);
|
|
this._selectText(sel.start - hlen, sel.value.length);
|
|
} else {
|
|
this.set("value", `${pre}${hval}${sel.value}${tail}${post}`);
|
|
this._selectText(sel.start + hlen, sel.value.length);
|
|
}
|
|
} else {
|
|
const lines = sel.value.split("\n");
|
|
|
|
let [hval, hlen] = getHead(head);
|
|
if (
|
|
lines.length === 1 &&
|
|
pre.slice(-tlen) === tail &&
|
|
post.slice(0, hlen) === hval
|
|
) {
|
|
this.set(
|
|
"value",
|
|
`${pre.slice(0, -hlen)}${sel.value}${post.slice(tlen)}`
|
|
);
|
|
this._selectText(sel.start - hlen, sel.value.length);
|
|
} else {
|
|
const contents = this._getMultilineContents(
|
|
lines,
|
|
head,
|
|
hval,
|
|
hlen,
|
|
tail,
|
|
tlen,
|
|
opts
|
|
);
|
|
|
|
this.set("value", `${pre}${contents}${post}`);
|
|
if (lines.length === 1 && tlen > 0) {
|
|
this._selectText(sel.start + hlen, sel.value.length);
|
|
} else {
|
|
this._selectText(sel.start, contents.length);
|
|
}
|
|
}
|
|
}
|
|
},
|
|
|
|
_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);
|
|
}
|
|
},
|
|
|
|
_replaceText(oldVal, newVal, opts) {
|
|
const val = this.get("value");
|
|
const needleStart = val.indexOf(oldVal);
|
|
|
|
if (needleStart === -1) {
|
|
// Nothing to replace.
|
|
return;
|
|
}
|
|
|
|
const textarea = this.$("textarea.d-editor-input")[0];
|
|
|
|
// Determine post-replace selection.
|
|
const newSelection = determinePostReplaceSelection({
|
|
selection: { start: textarea.selectionStart, end: textarea.selectionEnd },
|
|
needle: { start: needleStart, end: needleStart + oldVal.length },
|
|
replacement: { start: needleStart, end: needleStart + newVal.length }
|
|
});
|
|
|
|
if (opts && opts.index && opts.regex) {
|
|
let i = -1;
|
|
const newValue = val.replace(opts.regex, match => {
|
|
i++;
|
|
return i === opts.index ? newVal : match;
|
|
});
|
|
this.set("value", newValue);
|
|
} else {
|
|
// Replace value (side effect: cursor at the end).
|
|
this.set("value", val.replace(oldVal, newVal));
|
|
}
|
|
|
|
if ($("textarea.d-editor-input").is(":focus")) {
|
|
// Restore cursor.
|
|
this._selectText(
|
|
newSelection.start,
|
|
newSelection.end - newSelection.start
|
|
);
|
|
}
|
|
},
|
|
|
|
_addBlock(sel, text) {
|
|
text = (text || "").trim();
|
|
if (text.length === 0) {
|
|
return;
|
|
}
|
|
|
|
let pre = sel.pre;
|
|
let post = sel.value + sel.post;
|
|
|
|
if (pre.length > 0) {
|
|
pre = pre.replace(/\n*$/, "\n\n");
|
|
}
|
|
|
|
if (post.length > 0) {
|
|
post = post.replace(/^\n*/, "\n\n");
|
|
} else {
|
|
post = "\n";
|
|
}
|
|
|
|
const value = pre + text + post;
|
|
const $textarea = this.$("textarea.d-editor-input");
|
|
|
|
this.set("value", value);
|
|
|
|
$textarea.val(value);
|
|
$textarea.prop("selectionStart", (pre + text).length + 2);
|
|
$textarea.prop("selectionEnd", (pre + text).length + 2);
|
|
|
|
this._focusTextArea();
|
|
},
|
|
|
|
_addText(sel, text, options) {
|
|
const $textarea = this.$("textarea.d-editor-input");
|
|
|
|
if (options && options.ensureSpace) {
|
|
if ((sel.pre + "").length > 0) {
|
|
if (!sel.pre.match(/\s$/)) {
|
|
text = " " + text;
|
|
}
|
|
}
|
|
if ((sel.post + "").length > 0) {
|
|
if (!sel.post.match(/^\s/)) {
|
|
text = text + " ";
|
|
}
|
|
}
|
|
}
|
|
|
|
const insert = `${sel.pre}${text}`;
|
|
const value = `${insert}${sel.post}`;
|
|
this.set("value", value);
|
|
$textarea.val(value);
|
|
$textarea.prop("selectionStart", insert.length);
|
|
$textarea.prop("selectionEnd", insert.length);
|
|
|
|
this._focusTextArea();
|
|
},
|
|
|
|
_extractTable(text) {
|
|
if (text.endsWith("\n")) {
|
|
text = text.substring(0, text.length - 1);
|
|
}
|
|
|
|
let rows = text.split("\n");
|
|
|
|
if (rows.length > 1) {
|
|
const columns = rows.map(r => r.split("\t").length);
|
|
const isTable =
|
|
columns.reduce((a, b) => a && columns[0] === b && b > 1) &&
|
|
!(columns[0] === 2 && rows[0].split("\t")[0].match(/^•$|^\d+.$/)); // to skip tab delimited lists
|
|
|
|
if (isTable) {
|
|
const splitterRow = [...Array(columns[0])].map(() => "---").join("\t");
|
|
rows.splice(1, 0, splitterRow);
|
|
|
|
return (
|
|
"|" + rows.map(r => r.split("\t").join("|")).join("|\n|") + "|\n"
|
|
);
|
|
}
|
|
}
|
|
return null;
|
|
},
|
|
|
|
_toggleDirection() {
|
|
const $textArea = $(".d-editor-input");
|
|
let currentDir = $textArea.attr("dir") ? $textArea.attr("dir") : siteDir(),
|
|
newDir = currentDir === "ltr" ? "rtl" : "ltr";
|
|
|
|
$textArea.attr("dir", newDir).focus();
|
|
},
|
|
|
|
paste(e) {
|
|
if (!$(".d-editor-input").is(":focus")) {
|
|
return;
|
|
}
|
|
|
|
const isComposer = $("#reply-control .d-editor-input").is(":focus");
|
|
let { clipboard, canPasteHtml } = clipboardData(e, isComposer);
|
|
|
|
let plainText = clipboard.getData("text/plain");
|
|
let html = clipboard.getData("text/html");
|
|
let handled = false;
|
|
|
|
const { pre, lineVal } = this._getSelected(null, { lineVal: true });
|
|
const isInlinePasting = pre.match(/[^\n]$/);
|
|
const isCodeBlock = isInside(pre, /(^|\n)```/g);
|
|
|
|
if (
|
|
plainText &&
|
|
this.siteSettings.enable_rich_text_paste &&
|
|
!isInlinePasting &&
|
|
!isCodeBlock
|
|
) {
|
|
plainText = plainText.trim().replace(/\r/g, "");
|
|
const table = this._extractTable(plainText);
|
|
if (table) {
|
|
this.appEvents.trigger("composer:insert-text", table);
|
|
handled = true;
|
|
}
|
|
}
|
|
|
|
if (canPasteHtml && plainText) {
|
|
if (isInlinePasting) {
|
|
canPasteHtml = !(
|
|
lineVal.match(/^```/) ||
|
|
isInside(pre, /`/g) ||
|
|
lineVal.match(/^ /)
|
|
);
|
|
} else {
|
|
canPasteHtml = !isCodeBlock;
|
|
}
|
|
}
|
|
|
|
if (canPasteHtml && !handled) {
|
|
let markdown = toMarkdown(html);
|
|
|
|
if (!plainText || plainText.length < markdown.length) {
|
|
if (isInlinePasting) {
|
|
markdown = markdown.replace(/^#+/, "").trim();
|
|
markdown = pre.match(/\S$/) ? ` ${markdown}` : markdown;
|
|
}
|
|
|
|
this.appEvents.trigger("composer:insert-text", markdown);
|
|
handled = true;
|
|
}
|
|
}
|
|
|
|
if (handled) {
|
|
e.preventDefault();
|
|
}
|
|
},
|
|
|
|
// ensures textarea scroll position is correct
|
|
_focusTextArea() {
|
|
const $textarea = this.$("textarea.d-editor-input");
|
|
Ember.run.scheduleOnce("afterRender", () => $textarea.blur().focus());
|
|
},
|
|
|
|
actions: {
|
|
emoji() {
|
|
if (this.get("disabled")) {
|
|
return;
|
|
}
|
|
|
|
this.set("isEditorFocused", $("textarea.d-editor-input").is(":focus"));
|
|
this.set("emojiPickerIsActive", !this.get("emojiPickerIsActive"));
|
|
},
|
|
|
|
emojiSelected(code) {
|
|
let selected = this._getSelected();
|
|
const captures = selected.pre.match(/\B:(\w*)$/);
|
|
|
|
if (_.isEmpty(captures)) {
|
|
this._addText(selected, `:${code}:`);
|
|
} else {
|
|
let numOfRemovedChars = selected.pre.length - captures[1].length;
|
|
selected.pre = selected.pre.slice(
|
|
0,
|
|
selected.pre.length - captures[1].length
|
|
);
|
|
selected.start -= numOfRemovedChars;
|
|
selected.end -= numOfRemovedChars;
|
|
this._addText(selected, `${code}:`);
|
|
}
|
|
},
|
|
|
|
toolbarButton(button) {
|
|
if (this.get("disabled")) {
|
|
return;
|
|
}
|
|
|
|
const selected = this._getSelected(button.trimLeading);
|
|
const toolbarEvent = {
|
|
selected,
|
|
selectText: (from, length) => this._selectText(from, length),
|
|
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),
|
|
replaceText: text => this._addText({ pre: "", post: "" }, text),
|
|
getText: () => this.get("value"),
|
|
toggleDirection: () => this._toggleDirection()
|
|
};
|
|
|
|
if (button.sendAction) {
|
|
return button.sendAction(toolbarEvent);
|
|
} else {
|
|
button.perform(toolbarEvent);
|
|
}
|
|
},
|
|
|
|
showLinkModal() {
|
|
if (this.get("disabled")) {
|
|
return;
|
|
}
|
|
|
|
this.set("linkUrl", "");
|
|
this.set("linkText", "");
|
|
|
|
this._lastSel = this._getSelected();
|
|
|
|
if (this._lastSel) {
|
|
this.set("linkText", this._lastSel.value.trim());
|
|
}
|
|
|
|
this.set("insertLinkHidden", false);
|
|
},
|
|
|
|
formatCode() {
|
|
if (this.get("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}`
|
|
);
|
|
}
|
|
}
|
|
},
|
|
|
|
insertLink() {
|
|
const origLink = this.get("linkUrl");
|
|
const linkUrl =
|
|
origLink.indexOf("://") === -1 ? `http://${origLink}` : origLink;
|
|
const sel = this._lastSel;
|
|
|
|
if (Ember.isEmpty(linkUrl)) {
|
|
return;
|
|
}
|
|
|
|
const linkText = this.get("linkText") || "";
|
|
if (linkText.length) {
|
|
this._addText(sel, `[${linkText}](${linkUrl})`);
|
|
} else {
|
|
if (sel.value) {
|
|
this._addText(sel, `[${sel.value}](${linkUrl})`);
|
|
} else {
|
|
this._addText(sel, `[${origLink}](${linkUrl})`);
|
|
this._selectText(sel.start + 1, origLink.length);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|