Kaynağa Gözat

Add composer field type

Angus McLeod 7 yıl önce
ebeveyn
işleme
9dd6efdaa2

+ 9 - 0
assets/javascripts/wizard-custom-lib.js

@@ -2,3 +2,12 @@
 //= require discourse/lib/utilities
 //= require discourse/lib/offset-calculator
 //= require discourse/lib/lock-on
+//= require discourse/lib/text-direction
+//= require discourse/helpers/parse-html
+//= require discourse/lib/to-markdown
+//= require discourse/lib/load-script
+
+//= require markdown-it-bundle
+//= require pretty-text/engines/discourse-markdown-it
+//= require pretty-text/engines/discourse-markdown/helpers
+//= require pretty-text/pretty-text

+ 6 - 1
assets/javascripts/wizard-custom.js

@@ -10,8 +10,13 @@
 
 //= require discourse/components/user-selector
 //= require discourse/helpers/user-avatar
-
+//= require discourse/components/conditional-loading-spinner
+//= require discourse/templates/components/conditional-loading-spinner
+//= require discourse/components/d-button
+//= require discourse/templates/components/d-button
+//= require discourse/components/d-editor-modal
 //= require lodash.js
+//= require mousetrap.js
 
 window.Discourse = {}
 window.Wizard = {};

+ 703 - 0
assets/javascripts/wizard/components/wizard-editor.js.es6

@@ -0,0 +1,703 @@
+/*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(site) {
+    this.shortcuts = {};
+
+    this.groups = [
+      {group: 'fontStyles', buttons: []},
+      {group: 'insertions', buttons: []},
+      {group: 'extras', buttons: []},
+      {group: 'mobileExtras', 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,
+
+  @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();
+    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', '');
+    }
+  }
+});

+ 14 - 0
assets/javascripts/wizard/components/wizard-field-composer.js.es6

@@ -0,0 +1,14 @@
+import { default as computed } from 'ember-addons/ember-computed-decorators';
+
+export default Ember.Component.extend({
+  @computed('showPreview')
+  togglePreviewLabel(showPreview) {
+    return showPreview ? 'wizard_composer.hide_preview' : 'wizard_composer.show_preview';
+  },
+
+  actions: {
+    togglePreview() {
+      this.toggleProperty('showPreview');
+    }
+  }
+});

+ 36 - 0
assets/javascripts/wizard/components/wizard-text-field.js.es6

@@ -0,0 +1,36 @@
+import computed from "ember-addons/ember-computed-decorators";
+import { siteDir, isRTL, isLTR } from "discourse/lib/text-direction";
+
+export default Ember.TextField.extend({
+  attributeBindings: ['autocorrect', 'autocapitalize', 'autofocus', 'maxLength', 'dir'],
+
+  @computed
+  dir() {
+    if (Wizard.SiteSettings.support_mixed_text_direction) {
+      let val = this.value;
+      if (val) {
+        return isRTL(val) ? 'rtl' : 'ltr';
+      } else {
+        return siteDir();
+      }
+    }
+  },
+
+  keyUp() {
+    if (Wizard.SiteSettings.support_mixed_text_direction) {
+      let val = this.value;
+      if (isRTL(val)) {
+        this.set('dir', 'rtl');
+      } else if (isLTR(val)) {
+        this.set('dir', 'ltr');
+      } else {
+        this.set('dir', siteDir());
+      }
+    }
+  },
+
+  @computed("placeholderKey")
+  placeholder(placeholderKey) {
+    return placeholderKey ? I18n.t(placeholderKey) : "";
+  }
+});

+ 1 - 1
assets/javascripts/wizard/initializers/custom.js.es6

@@ -159,7 +159,7 @@ export default {
       }.property('field.type', 'field.id')
     });
 
-    const StandardFields = ['text', 'textarea', 'dropdown', 'image', 'checkbox', 'user-selector', 'text-only'];
+    const StandardFields = ['text', 'textarea', 'dropdown', 'image', 'checkbox', 'user-selector', 'text-only', 'composer'];
 
     FieldModel.reopen({
       hasCustomCheck: false,

+ 92 - 0
assets/javascripts/wizard/lib/load-script.js.es6

@@ -0,0 +1,92 @@
+import { ajax } from 'wizard/lib/ajax';
+const _loaded = {};
+const _loading = {};
+
+function loadWithTag(path, cb) {
+  const head = document.getElementsByTagName('head')[0];
+
+  let finished = false;
+  let s = document.createElement('script');
+  s.src = path;
+  if (Ember.Test) {
+    Ember.Test.registerWaiter(() => finished);
+  }
+  head.appendChild(s);
+
+  s.onload = s.onreadystatechange = function(_, abort) {
+    finished = true;
+    if (abort || !s.readyState || s.readyState === "loaded" || s.readyState === "complete") {
+      s = s.onload = s.onreadystatechange = null;
+      if (!abort) {
+        Ember.run(null, cb);
+      }
+    }
+  };
+}
+
+export function loadCSS(url) {
+  return loadScript(url, { css: true });
+}
+
+export default function loadScript(url, opts) {
+
+  // TODO: Remove this once plugins have been updated not to use it:
+  if (url === "defer/html-sanitizer-bundle") { return Ember.RSVP.Promise.resolve(); }
+
+  opts = opts || {};
+
+  $('script').each((i, tag) => {
+    const src = tag.getAttribute('src');
+
+    if (src && (opts.scriptTag || src !== url)) {
+      _loaded[tag.getAttribute('src')] = true;
+    }
+  });
+
+
+  return new Ember.RSVP.Promise(function(resolve) {
+    url = Discourse.getURL(url);
+
+    // If we already loaded this url
+    if (_loaded[url]) { return resolve(); }
+    if (_loading[url]) { return _loading[url].then(resolve);}
+
+    let done;
+    _loading[url] = new Ember.RSVP.Promise(function(_done){
+      done = _done;
+    });
+
+    _loading[url].then(function(){
+      delete _loading[url];
+    });
+
+    const cb = function(data) {
+      if (opts && opts.css) {
+        $("head").append("<style>" + data + "</style>");
+      }
+      done();
+      resolve();
+      _loaded[url] = true;
+    };
+
+    let cdnUrl = url;
+
+    // Scripts should always load from CDN
+    // CSS is type text, to accept it from a CDN we would need to handle CORS
+    if (!opts.css && Discourse.CDN && url[0] === "/" && url[1] !== "/") {
+      cdnUrl = Discourse.CDN.replace(/\/$/,"") + url;
+    }
+
+    // Some javascript depends on the path of where it is loaded (ace editor)
+    // to dynamically load more JS. In that case, add the `scriptTag: true`
+    // option.
+    if (opts.scriptTag) {
+      if (Ember.testing) {
+        throw `In test mode scripts cannot be loaded async ${cdnUrl}`;
+      }
+      loadWithTag(cdnUrl, cb);
+    } else {
+      ajax({url: cdnUrl, dataType: opts.css ? "text": "script", cache: true}).then(cb);
+    }
+  });
+}

+ 18 - 0
assets/javascripts/wizard/lib/text-lite.js.es6

@@ -0,0 +1,18 @@
+import loadScript from './load-script';
+import { default as PrettyText } from 'pretty-text/pretty-text';
+
+export function cook(text, options) {
+  return new Handlebars.SafeString(new PrettyText(options).cook(text));
+}
+
+// everything should eventually move to async API and this should be renamed
+// cook
+export function cookAsync(text, options) {
+  if (Discourse.MarkdownItURL) {
+    return loadScript(Discourse.MarkdownItURL)
+      .then(()=>cook(text, options))
+      .catch(e => Ember.Logger.error(e));
+  } else {
+    return Ember.RSVP.Promise.resolve(cook(text));
+  }
+}

+ 60 - 0
assets/javascripts/wizard/lib/utilities-lite.js.es6

@@ -0,0 +1,60 @@
+// lite version of discourse/lib/utilities
+
+export function determinePostReplaceSelection({ selection, needle, replacement }) {
+  const diff = (replacement.end - replacement.start) - (needle.end - needle.start);
+
+  if (selection.end <= needle.start) {
+    // Selection ends (and starts) before needle.
+    return { start: selection.start, end: selection.end };
+  } else if (selection.start <= needle.start) {
+    // Selection starts before needle...
+    if (selection.end < needle.end) {
+      // ... and ends inside needle.
+      return { start: selection.start, end: needle.start };
+    } else {
+      // ... and spans needle completely.
+      return { start: selection.start, end: selection.end + diff };
+    }
+  } else if (selection.start < needle.end) {
+    // Selection starts inside needle...
+    if (selection.end <= needle.end) {
+      // ... and ends inside needle.
+      return { start: replacement.end, end: replacement.end };
+    } else {
+      // ... and spans end of needle.
+      return { start: replacement.end, end: selection.end + diff };
+    }
+  } else {
+    // Selection starts (and ends) behind needle.
+    return { start: selection.start + diff, end: selection.end + diff };
+  }
+}
+
+const toArray = items => {
+  items = items || [];
+
+  if (!Array.isArray(items)) {
+    return Array.from(items);
+  }
+
+  return items;
+};
+
+export function clipboardData(e, canUpload) {
+  const clipboard = e.clipboardData ||
+                      e.originalEvent.clipboardData ||
+                      e.delegatedEvent.originalEvent.clipboardData;
+
+  const types = toArray(clipboard.types);
+  let files = toArray(clipboard.files);
+
+  if (types.includes("Files") && files.length === 0) { // for IE
+    files = toArray(clipboard.items).filter(i => i.kind === "file");
+  }
+
+  canUpload = files && canUpload && !types.includes("text/plain");
+  const canUploadImage = canUpload && files.filter(f => f.type.match('^image/'))[0];
+  const canPasteHtml = Discourse.SiteSettings.enable_rich_text_paste && types.includes("text/html") && !canUploadImage;
+
+  return { clipboard, types, canUpload, canPasteHtml };
+}

+ 53 - 0
assets/javascripts/wizard/templates/components/wizard-editor.hbs

@@ -0,0 +1,53 @@
+<div class='d-editor-overlay hidden'></div>
+
+<div class='d-editor-modals'>
+  {{#d-editor-modal class="insert-link" hidden=insertLinkHidden okAction="insertLink"}}
+    <h3>{{i18n "wizard_composer.link_dialog_title"}}</h3>
+    {{wizard-text-field value=linkUrl placeholderKey="wizard_composer.link_url_placeholder" class="link-url"}}
+    {{wizard-text-field value=linkText placeholderKey="wizard_composer.link_optional_text" class="link-text"}}
+  {{/d-editor-modal}}
+</div>
+
+<div class='d-editor-container'>
+  {{#if showPreview}}
+    <div class="d-editor-preview-wrapper {{if forcePreview 'force-preview'}}">
+      <div class="d-editor-preview">
+        {{{preview}}}
+      </div>
+    </div>
+  {{else}}
+    <div class="d-editor-textarea-wrapper">
+      <div class='d-editor-button-bar'>
+        {{#each toolbar.groups as |group|}}
+          {{#each group.buttons as |b|}}
+            {{#if b.popupMenu}}
+              {{toolbar-popup-menu-options
+                onPopupMenuAction=onPopupMenuAction
+                onExpand=(action b.action b)
+                title=b.title
+                headerIcon=b.icon
+                class=b.className
+                content=popupMenuOptions}}
+            {{else}}
+              <div>{{d.icon}}</div>
+              {{d-button
+                action=b.action
+                actionParam=b
+                translatedTitle=b.title
+                label=b.label
+                icon=b.icon
+                class=b.className}}
+            {{/if}}
+          {{/each}}
+
+          {{#unless group.lastGroup}}
+            <div class='d-editor-spacer'></div>
+          {{/unless}}
+        {{/each}}
+      </div>
+
+      {{conditional-loading-spinner condition=loading}}
+      {{textarea tabindex=tabindex value=value class="d-editor-input" placeholder=placeholderTranslated}}
+    </div>
+  {{/if}}
+</div>

+ 2 - 0
assets/javascripts/wizard/templates/components/wizard-field-composer.hbs

@@ -0,0 +1,2 @@
+{{d-button class='wizard-btn primary' action='togglePreview' label=togglePreviewLabel}}
+{{wizard-editor showPreview=showPreview value=field.value}}

+ 168 - 0
assets/stylesheets/wizard/wizard_composer.scss

@@ -0,0 +1,168 @@
+@import 'wizard_variables';
+
+.d-editor-container {
+  display: flex;
+  flex-grow: 1;
+  max-width: 100%;
+}
+
+.d-editor-overlay {
+  position: absolute;
+  background-color: black;
+  opacity: 0.8;
+  z-index: z("modal","overlay");
+}
+
+.d-editor-modals {
+  position: absolute;
+  z-index: z("modal","content");
+}
+
+.d-editor {
+  display: flex;
+  flex-grow: 1;
+  min-height: 0;
+}
+
+.d-editor .d-editor-modal {
+  min-width: 400px;
+  @media screen and (max-width: 424px) {
+    min-width: 300px;
+  }
+  position: absolute;
+  background-color: $secondary;
+  border: 1px solid $primary;
+  padding: 1em;
+  top: 25px;
+
+  input {
+    width: 95%;
+  }
+  h3 {
+    margin-bottom: 0.5em;
+  }
+}
+
+.d-editor-textarea-wrapper,
+.d-editor-preview-wrapper {
+  flex: 1;
+}
+
+.d-editor-textarea-wrapper {
+  display: flex;
+  flex-direction: column;
+  background-color: $secondary;
+  position: relative;
+  border: 1px solid #919191;
+
+  textarea {
+    background: transparent;
+  }
+}
+
+.d-editor-preview-wrapper {
+  display: flex;
+  flex-direction: column;
+}
+
+.d-editor-button-bar {
+  display: flex;
+  align-items: center;
+  border-bottom: none;
+  min-height: 30px;
+  padding-left: 3px;
+  border-bottom: 1px solid #e9e9e9;
+
+  button {
+    background-color: transparent;
+    color: $primary;
+  }
+
+  .btn:not(.no-text) {
+    font-size: 1.1487em;
+  }
+
+  .btn.bold {
+    font-weight: bolder;
+  }
+
+  .btn.italic {
+    font-style: italic;
+  }
+}
+
+.d-editor-spacer {
+  width: 1px;
+  height: 20px;
+  margin: 0 5px;
+  background-color: $primary;
+  display: inline-block;
+}
+
+.d-editor-preview-wrapper {
+  overflow: auto;
+  cursor: default;
+}
+
+.d-editor-input,
+.d-editor-preview {
+  box-sizing: border-box;
+  flex: 1 1 100%;
+  width: 100%;
+  margin: 0;
+  min-height: auto;
+  word-wrap: break-word;
+  -webkit-appearance: none;
+  border-radius: 0;
+  &:focus {
+    box-shadow: none;
+    border: 0;
+    outline: 0;
+  }
+}
+
+.d-editor-input {
+  border: 0;
+  padding: 10px;
+  height: 100%;
+  overflow-x: hidden;
+  resize: none;
+}
+
+.d-editor-preview {
+  height: auto;
+}
+
+.d-editor-plugin {
+  display: flex;
+  flex: 1 1;
+  overflow: auto;
+}
+
+.composing-whisper .d-editor-preview {
+  font-style: italic;
+  color: $primary !important;
+}
+
+.d-editor-preview > *:first-child {
+  margin-top: 0;
+}
+
+.hide-preview .d-editor-preview-wrapper {
+  display: none;
+  flex: 0;
+}
+
+////
+
+.d-editor {
+  max-height: 300px;
+}
+
+.d-editor-modal.hidden {
+  display: none;
+}
+
+.d-editor-button-bar .btn {
+  border: none;
+}

+ 2 - 0
assets/stylesheets/wizard/wizard_custom.scss

@@ -1,3 +1,5 @@
+@import 'wizard_variables';
+
 .custom-wizard {
   background-color: initial;
 

+ 2 - 0
assets/stylesheets/wizard/wizard_custom_mobile.scss

@@ -1,3 +1,5 @@
+@import 'wizard_variables';
+
 .custom-wizard {
   .wizard-step-form {
     .wizard-btn {

+ 10 - 0
assets/stylesheets/wizard/wizard_variables.scss

@@ -0,0 +1,10 @@
+$primary:           #222222 !default;
+$secondary:         #ffffff !default;
+$tertiary:          #0088cc !default;
+$quaternary:        #e45735 !default;
+$header_background: #ffffff !default;
+$header_primary:    #333333 !default;
+$highlight:         #ffff4d !default;
+$danger:            #e45735 !default;
+$success:           #009900 !default;
+$love:              #fa6c8d !default;

+ 37 - 0
config/locales/client.en.yml

@@ -157,3 +157,40 @@ en:
       completed: "You have completed this wizard."
       not_permitted: "You need to be trust level {{level}} or higher to access this wizard."
       none: "There is no wizard here."
+
+    wizard_composer:
+      show_preview: "Show Preview"
+      hide_preview: "Hide Preview"
+      quote_post_title: "Quote whole post"
+      bold_label: "B"
+      bold_title: "Strong"
+      bold_text: "strong text"
+      italic_label: "I"
+      italic_title: "Emphasis"
+      italic_text: "emphasized text"
+      link_title: "Hyperlink"
+      link_description: "enter link description here"
+      link_dialog_title: "Insert Hyperlink"
+      link_optional_text: "optional title"
+      link_url_placeholder: "http://example.com"
+      quote_title: "Blockquote"
+      quote_text: "Blockquote"
+      blockquote_text: "Blockquote"
+      code_title: "Preformatted text"
+      code_text: "indent preformatted text by 4 spaces"
+      paste_code_text: "type or paste code here"
+      upload_title: "Upload"
+      upload_description: "enter upload description here"
+      olist_title: "Numbered List"
+      ulist_title: "Bulleted List"
+      list_item: "List item"
+      toggle_direction: "Toggle Direction"
+      help: "Markdown Editing Help"
+      collapse: "minimize the composer panel"
+      abandon: "close composer and discard draft"
+      modal_ok: "OK"
+      modal_cancel: "Cancel"
+      cant_send_pm: "Sorry, you can't send a message to %{username}."
+      yourself_confirm:
+        title: "Did you forget to add recipients?"
+        body: "Right now this message is only being sent to yourself!"

+ 1 - 1
lib/field.rb

@@ -1,6 +1,6 @@
 class CustomWizard::Field
   def self.types
-    @types ||= ['text', 'textarea', 'dropdown', 'image', 'checkbox', 'user-selector', 'text-only']
+    @types ||= ['text', 'textarea', 'dropdown', 'image', 'checkbox', 'user-selector', 'text-only', 'composer']
   end
 
   def self.require_assets

+ 2 - 0
plugin.rb

@@ -18,6 +18,8 @@ if Rails.env.production?
     wizard-custom.js
     wizard-plugin.js
     stylesheets/wizard/wizard_custom.scss
+    stylesheets/wizard/wizard_composer.scss
+    stylesheets/wizard/wizard_variables.scss
     stylesheets/wizard/wizard_custom_mobile.scss
   }
 end

+ 2 - 0
views/layouts/wizard.html.erb

@@ -2,6 +2,8 @@
   <head>
     <%= discourse_stylesheet_link_tag :wizard, theme_key: nil %>
     <%= stylesheet_link_tag "wizard_custom", media: "all", "data-turbolinks-track" => "reload" %>
+    <%= stylesheet_link_tag "wizard_composer", media: "all", "data-turbolinks-track" => "reload" %>
+    <%= stylesheet_link_tag "wizard_variables", media: "all", "data-turbolinks-track" => "reload" %>
     <%= stylesheet_link_tag "wizard_custom_mobile", media: "all", "data-turbolinks-track" => "reload" if mobile_view?%>
     <%- if theme_key %>
       <%= discourse_stylesheet_link_tag (mobile_view? ? :mobile_theme : :desktop_theme) %>