123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697 |
- /* eslint no-undef: 0 */
- /*global Mousetrap:true */
- import { default as computed, on, observes } from 'ember-addons/ember-computed-decorators';
- import { cookAsync } from '../lib/text-lite';
- import { getRegister } from 'discourse-common/lib/get-owner';
- import { siteDir } from 'discourse/lib/text-direction';
- import { determinePostReplaceSelection, clipboardData } from '../lib/utilities-lite';
- import toMarkdown from 'discourse/lib/to-markdown';
- // 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() {
- this.shortcuts = {};
- this.groups = [
- {group: 'fontStyles', buttons: []},
- {group: 'insertions', buttons: []},
- {group: 'extras', buttons: []}
- ];
- this.addButton({
- trimLeading: true,
- id: 'bold',
- group: 'fontStyles',
- icon: 'bold',
- label: getButtonLabel('wizard_composer.bold_label', 'B'),
- shortcut: 'B',
- perform: e => e.applySurround('**', '**', 'bold_text')
- });
- this.addButton({
- trimLeading: true,
- id: 'italic',
- group: 'fontStyles',
- icon: 'italic',
- label: getButtonLabel('wizard_composer.italic_label', 'I'),
- shortcut: 'I',
- perform: e => e.applySurround('_', '_', 'italic_text')
- });
- this.addButton({id: 'link', group: 'insertions', shortcut: 'K', action: 'showLinkModal'});
- 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: 'formatCode'});
- this.addButton({
- id: 'bullet',
- group: 'extras',
- icon: 'list-ul',
- shortcut: 'Shift+8',
- title: 'wizard_composer.ulist_title',
- perform: e => e.applyList('* ', 'list_item')
- });
- this.addButton({
- id: 'list',
- group: 'extras',
- icon: 'list-ol',
- shortcut: 'Shift+7',
- title: 'wizard_composer.olist_title',
- perform: e => e.applyList(i => !i ? "1. " : `${parseInt(i) + 1}. `, 'list_item')
- });
- if (Wizard.SiteSettings.support_mixed_text_direction) {
- this.addButton({
- id: 'toggle-direction',
- group: 'extras',
- icon: 'exchange',
- shortcut: 'Shift+6',
- title: 'wizard_composer.toggle_direction',
- 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 `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 || 'toolbarButton',
- perform: button.perform || function() { },
- trimLeading: button.trimLeading,
- popupMenu: button.popupMenu || false
- };
- if (button.sendAction) {
- createdButton.sendAction = button.sendAction;
- }
- const title = I18n.t(button.title || `wizard_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 default Ember.Component.extend({
- classNames: ['d-editor'],
- ready: false,
- insertLinkHidden: true,
- linkUrl: '',
- linkText: '',
- lastSel: null,
- _mouseTrap: null,
- showPreview: false,
- _readyNow() {
- this.set('ready', true);
- if (this.get('autofocus')) {
- this.$('textarea').focus();
- }
- },
- init() {
- this._super();
- this.register = getRegister(this);
- },
- didInsertElement() {
- this._super();
- Ember.run.scheduleOnce('afterRender', this, this._readyNow);
- const mouseTrap = Mousetrap(this.$('.d-editor-input')[0]);
- const shortcuts = this.get('toolbar.shortcuts');
- // for some reason I am having trouble bubbling this so hack it in
- mouseTrap.bind(['ctrl+alt+f'], (event) =>{
- this.appEvents.trigger('header:keyboard-trigger', {type: 'search', event});
- return true;
- });
- Object.keys(shortcuts).forEach(sc => {
- const button = shortcuts[sc];
- mouseTrap.bind(sc, () => {
- this.send(button.action, button);
- return false;
- });
- });
- // disable clicking on links in the preview
- this.$('.d-editor-preview').on('click.preview', e => {
- if ($(e.target).is("a")) {
- e.preventDefault();
- return false;
- }
- });
- if (this.get('composerEvents')) {
- this.appEvents.on('composer:insert-block', text => this._addBlock(this._getSelected(), text));
- this.appEvents.on('composer:insert-text', (text, options) => this._addText(this._getSelected(), text, options));
- this.appEvents.on('composer:replace-text', (oldVal, newVal) => this._replaceText(oldVal, newVal));
- }
- this._mouseTrap = mouseTrap;
- },
- @on('willDestroyElement')
- _shutDown() {
- if (this.get('composerEvents')) {
- this.appEvents.off('composer:insert-block');
- this.appEvents.off('composer:insert-text');
- this.appEvents.off('composer:replace-text');
- }
- const mouseTrap = this._mouseTrap;
- Object.keys(this.get('toolbar.shortcuts')).forEach(sc => mouseTrap.unbind(sc));
- mouseTrap.unbind('ctrl+/','command+/');
- this.$('.d-editor-preview').off('click.preview');
- },
- @computed
- toolbar() {
- const toolbar = new Toolbar(this.site);
- _createCallbacks.forEach(cb => cb(toolbar));
- this.sendAction('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;
- this.sendAction('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);
- }
- },
- _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 (!!navigator.platform && /iPad|iPhone|iPod/.test(navigator.platform)) {
- $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(`wizard_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(`wizard_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) {
- 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 }
- });
- // Replace value (side effect: cursor at the end).
- this.set('value', val.replace(oldVal, newVal));
- // 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);
- Ember.run.scheduleOnce("afterRender", () => $textarea.focus());
- },
- _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);
- Ember.run.scheduleOnce("afterRender", () => $textarea.focus());
- },
- _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;
- if (plainText) {
- plainText = plainText.trim().replace(/\r/g,"");
- const table = this._extractTable(plainText);
- if (table) {
- this.appEvents.trigger('composer:insert-text', table);
- handled = true;
- }
- }
- const { pre, lineVal } = this._getSelected(null, {lineVal: true});
- const isInlinePasting = pre.match(/[^\n]$/);
- if (canPasteHtml && plainText) {
- if (isInlinePasting) {
- canPasteHtml = !(lineVal.match(/^```/) || isInside(pre, /`/g) || lineVal.match(/^ /));
- } else {
- canPasteHtml = !isInside(pre, /(^|\n)```/g);
- }
- }
- 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();
- }
- },
- keyPress(e) {
- if (e.keyCode === 13) {
- const selected = this._getSelected();
- this._addText(selected, '\n');
- return false;
- }
- },
- actions: {
- toolbarButton(button) {
- 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 this.sendAction(button.sendAction, toolbarEvent);
- } else {
- button.perform(toolbarEvent);
- }
- },
- showLinkModal() {
- this._lastSel = this._getSelected();
- if (this._lastSel) {
- this.set("linkText", this._lastSel.value.trim());
- }
- this.set('insertLinkHidden', false);
- },
- formatCode() {
- 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(`wizard_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);
- }
- }
- this.set('linkUrl', '');
- this.set('linkText', '');
- }
- }
- });
|