diff --git a/assets/javascripts/wizard-custom-lib.js b/assets/javascripts/wizard-custom-lib.js index ec39b956..81cde335 100644 --- a/assets/javascripts/wizard-custom-lib.js +++ b/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 diff --git a/assets/javascripts/wizard-custom.js b/assets/javascripts/wizard-custom.js index 7dbb1497..6c8cd3fc 100644 --- a/assets/javascripts/wizard-custom.js +++ b/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 = {}; diff --git a/assets/javascripts/wizard/components/wizard-editor.js.es6 b/assets/javascripts/wizard/components/wizard-editor.js.es6 new file mode 100644 index 00000000..457fc618 --- /dev/null +++ b/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', ''); + } + } +}); diff --git a/assets/javascripts/wizard/components/wizard-field-composer.js.es6 b/assets/javascripts/wizard/components/wizard-field-composer.js.es6 new file mode 100644 index 00000000..86f5dd9e --- /dev/null +++ b/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'); + } + } +}); diff --git a/assets/javascripts/wizard/components/wizard-text-field.js.es6 b/assets/javascripts/wizard/components/wizard-text-field.js.es6 new file mode 100644 index 00000000..63a086db --- /dev/null +++ b/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) : ""; + } +}); diff --git a/assets/javascripts/wizard/initializers/custom.js.es6 b/assets/javascripts/wizard/initializers/custom.js.es6 index 7c4b3b90..e7737dd1 100644 --- a/assets/javascripts/wizard/initializers/custom.js.es6 +++ b/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, diff --git a/assets/javascripts/wizard/lib/load-script.js.es6 b/assets/javascripts/wizard/lib/load-script.js.es6 new file mode 100644 index 00000000..8eb21583 --- /dev/null +++ b/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(""); + } + 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); + } + }); +} diff --git a/assets/javascripts/wizard/lib/text-lite.js.es6 b/assets/javascripts/wizard/lib/text-lite.js.es6 new file mode 100644 index 00000000..cdcab877 --- /dev/null +++ b/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)); + } +} diff --git a/assets/javascripts/wizard/lib/utilities-lite.js.es6 b/assets/javascripts/wizard/lib/utilities-lite.js.es6 new file mode 100644 index 00000000..564dc55f --- /dev/null +++ b/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 }; +} diff --git a/assets/javascripts/wizard/templates/components/wizard-editor.hbs b/assets/javascripts/wizard/templates/components/wizard-editor.hbs new file mode 100644 index 00000000..3d736e9e --- /dev/null +++ b/assets/javascripts/wizard/templates/components/wizard-editor.hbs @@ -0,0 +1,53 @@ + + +
+ {{#d-editor-modal class="insert-link" hidden=insertLinkHidden okAction="insertLink"}} +

{{i18n "wizard_composer.link_dialog_title"}}

+ {{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}} +
+ +
+ {{#if showPreview}} +
+
+ {{{preview}}} +
+
+ {{else}} +
+
+ {{#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}} +
{{d.icon}}
+ {{d-button + action=b.action + actionParam=b + translatedTitle=b.title + label=b.label + icon=b.icon + class=b.className}} + {{/if}} + {{/each}} + + {{#unless group.lastGroup}} +
+ {{/unless}} + {{/each}} +
+ + {{conditional-loading-spinner condition=loading}} + {{textarea tabindex=tabindex value=value class="d-editor-input" placeholder=placeholderTranslated}} +
+ {{/if}} +
diff --git a/assets/javascripts/wizard/templates/components/wizard-field-composer.hbs b/assets/javascripts/wizard/templates/components/wizard-field-composer.hbs new file mode 100644 index 00000000..8a5c4ebb --- /dev/null +++ b/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}} diff --git a/assets/stylesheets/wizard/wizard_composer.scss b/assets/stylesheets/wizard/wizard_composer.scss new file mode 100644 index 00000000..c04cc352 --- /dev/null +++ b/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; +} diff --git a/assets/stylesheets/wizard/wizard_custom.scss b/assets/stylesheets/wizard/wizard_custom.scss index 8a114231..2a0abb89 100644 --- a/assets/stylesheets/wizard/wizard_custom.scss +++ b/assets/stylesheets/wizard/wizard_custom.scss @@ -1,3 +1,5 @@ +@import 'wizard_variables'; + .custom-wizard { background-color: initial; diff --git a/assets/stylesheets/wizard/wizard_custom_mobile.scss b/assets/stylesheets/wizard/wizard_custom_mobile.scss index 116641db..77ff4f7c 100644 --- a/assets/stylesheets/wizard/wizard_custom_mobile.scss +++ b/assets/stylesheets/wizard/wizard_custom_mobile.scss @@ -1,3 +1,5 @@ +@import 'wizard_variables'; + .custom-wizard { .wizard-step-form { .wizard-btn { diff --git a/assets/stylesheets/wizard/wizard_variables.scss b/assets/stylesheets/wizard/wizard_variables.scss new file mode 100644 index 00000000..72041265 --- /dev/null +++ b/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; diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 5ea2fa15..81138fd0 100644 --- a/config/locales/client.en.yml +++ b/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!" diff --git a/lib/field.rb b/lib/field.rb index 95ff547e..a56ff40f 100644 --- a/lib/field.rb +++ b/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 diff --git a/plugin.rb b/plugin.rb index 4070f086..402277d0 100644 --- a/plugin.rb +++ b/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 diff --git a/views/layouts/wizard.html.erb b/views/layouts/wizard.html.erb index e3ba81f2..b94d1dbf 100644 --- a/views/layouts/wizard.html.erb +++ b/views/layouts/wizard.html.erb @@ -2,6 +2,8 @@ <%= 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) %>