Editor.vue 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400
  1. <template>
  2. <!-- Two-way Data-Binding -->
  3. <section class="editor">
  4. <div class="columns">
  5. <div class="column is-6">
  6. <b-field label="Format">
  7. <div>
  8. <b-radio v-model="form.radioFormat"
  9. @input="onFormatChange" :disabled="disabled" name="format"
  10. native-value="richtext"
  11. data-cy="check-richtext">{{ $t('campaigns.richText') }}</b-radio>
  12. <b-radio v-model="form.radioFormat"
  13. @input="onFormatChange" :disabled="disabled" name="format"
  14. native-value="html"
  15. data-cy="check-html">{{ $t('campaigns.rawHTML') }}</b-radio>
  16. <b-radio v-model="form.radioFormat"
  17. @input="onFormatChange" :disabled="disabled" name="format"
  18. native-value="markdown"
  19. data-cy="check-markdown">{{ $t('campaigns.markdown') }}</b-radio>
  20. <b-radio v-model="form.radioFormat"
  21. @input="onFormatChange" :disabled="disabled" name="format"
  22. native-value="plain"
  23. data-cy="check-plain">{{ $t('campaigns.plainText') }}</b-radio>
  24. </div>
  25. </b-field>
  26. </div>
  27. <div class="column is-6 has-text-right">
  28. <b-button @click="onTogglePreview" type="is-primary"
  29. icon-left="file-find-outline">{{ $t('campaigns.preview') }}</b-button>
  30. </div>
  31. </div>
  32. <!-- wsywig //-->
  33. <tiny-mce
  34. v-model="form.body"
  35. v-if="isRichtextReady && form.format === 'richtext'"
  36. :disabled="disabled"
  37. :init="richtextConf"
  38. />
  39. <!-- raw html editor //-->
  40. <div v-if="form.format === 'html'"
  41. ref="htmlEditor" id="html-editor" class="html-editor"></div>
  42. <!-- plain text / markdown editor //-->
  43. <b-input v-if="form.format === 'plain' || form.format === 'markdown'"
  44. v-model="form.body" @input="onEditorChange"
  45. type="textarea" name="content" ref="plainEditor" class="plain-editor" />
  46. <!-- campaign preview //-->
  47. <campaign-preview v-if="isPreviewing"
  48. @close="onTogglePreview"
  49. type="campaign"
  50. :id="id"
  51. :title="title"
  52. :contentType="form.format"
  53. :body="form.body"></campaign-preview>
  54. <!-- image picker -->
  55. <b-modal scroll="keep" :aria-modal="true" :active.sync="isMediaVisible" :width="900">
  56. <div class="modal-card content" style="width: auto">
  57. <section expanded class="modal-card-body">
  58. <media isModal @selected="onMediaSelect" />
  59. </section>
  60. </div>
  61. </b-modal>
  62. </section>
  63. </template>
  64. <script>
  65. import { mapState } from 'vuex';
  66. import CodeFlask from 'codeflask';
  67. import TurndownService from 'turndown';
  68. import { indent } from 'indent.js';
  69. import 'tinymce';
  70. import 'tinymce/icons/default';
  71. import 'tinymce/themes/silver';
  72. import 'tinymce/skins/ui/oxide/skin.css';
  73. import 'tinymce/plugins/autoresize';
  74. import 'tinymce/plugins/autolink';
  75. import 'tinymce/plugins/charmap';
  76. import 'tinymce/plugins/code';
  77. import 'tinymce/plugins/colorpicker';
  78. import 'tinymce/plugins/contextmenu';
  79. import 'tinymce/plugins/emoticons';
  80. import 'tinymce/plugins/emoticons/js/emojis';
  81. import 'tinymce/plugins/fullscreen';
  82. import 'tinymce/plugins/help';
  83. import 'tinymce/plugins/hr';
  84. import 'tinymce/plugins/image';
  85. import 'tinymce/plugins/imagetools';
  86. import 'tinymce/plugins/link';
  87. import 'tinymce/plugins/lists';
  88. import 'tinymce/plugins/paste';
  89. import 'tinymce/plugins/searchreplace';
  90. import 'tinymce/plugins/table';
  91. import 'tinymce/plugins/textcolor';
  92. import 'tinymce/plugins/visualblocks';
  93. import 'tinymce/plugins/visualchars';
  94. import 'tinymce/plugins/wordcount';
  95. import TinyMce from '@tinymce/tinymce-vue';
  96. import CampaignPreview from './CampaignPreview.vue';
  97. import Media from '../views/Media.vue';
  98. import { colors, uris } from '../constants';
  99. const turndown = new TurndownService();
  100. // Map of listmonk language codes to corresponding TinyMCE language files.
  101. const LANGS = {
  102. 'cs-cz': 'cs',
  103. de: 'de',
  104. es: 'es_419',
  105. fr: 'fr_FR',
  106. it: 'it_IT',
  107. pl: 'pl',
  108. pt: 'pt_PT',
  109. 'pt-BR': 'pt_BR',
  110. tr: 'tr',
  111. };
  112. export default {
  113. components: {
  114. Media,
  115. CampaignPreview,
  116. TinyMce,
  117. },
  118. props: {
  119. id: Number,
  120. title: String,
  121. body: String,
  122. contentType: String,
  123. disabled: Boolean,
  124. },
  125. data() {
  126. return {
  127. isPreviewing: false,
  128. isMediaVisible: false,
  129. isEditorFullscreen: false,
  130. isReady: false,
  131. isRichtextReady: false,
  132. richtextConf: {},
  133. form: {
  134. body: '',
  135. format: this.contentType,
  136. // Model bound to the checkboxes. This changes on click of the radio,
  137. // but is reverted by the change handler if the user cancels the
  138. // conversion warning. This is used to set the value of form.format
  139. // that the editor uses to render content.
  140. radioFormat: this.contentType,
  141. },
  142. // Last position of the cursor in the editor before the media popup
  143. // was opened. This is used to insert media on selection from the poup
  144. // where the caret may be lost.
  145. lastSel: null,
  146. // HTML editor.
  147. flask: null,
  148. };
  149. },
  150. methods: {
  151. initRichtextEditor() {
  152. const { lang } = this.serverConfig;
  153. this.richtextConf = {
  154. min_height: 500,
  155. plugins: [
  156. 'autoresize', 'autolink', 'charmap', 'code', 'emoticons', 'fullscreen', 'help',
  157. 'hr', 'image', 'imagetools', 'link', 'lists', 'paste', 'searchreplace',
  158. 'table', 'visualblocks', 'visualchars', 'wordcount',
  159. ],
  160. toolbar: `undo redo | formatselect styleselect fontsizeselect |
  161. bold italic underline strikethrough forecolor backcolor subscript superscript |
  162. alignleft aligncenter alignright alignjustify |
  163. bullist numlist table image | outdent indent | link hr removeformat |
  164. code fullscreen help`,
  165. skin: false,
  166. content_css: false,
  167. content_style: `
  168. body { font-family: 'Inter', sans-serif; font-size: 15px; }
  169. img { max-width: 100%; }
  170. a { color: ${colors.primary}; }
  171. table, td { border-color: #ccc;}
  172. `,
  173. file_picker_types: 'image',
  174. file_picker_callback: (callback) => {
  175. this.isMediaVisible = true;
  176. this.runTinyMceImageCallback = callback;
  177. },
  178. init_instance_callback: () => { this.isReady = true; },
  179. language: LANGS[lang] || null,
  180. language_url: LANGS[lang] ? `${uris.static}/tinymce/lang/${LANGS[lang]}.js` : null,
  181. };
  182. this.isRichtextReady = true;
  183. },
  184. initHTMLEditor() {
  185. // CodeFlask editor is rendered in a shadow DOM tree to keep its styles
  186. // sandboxed away from the global styles.
  187. const el = document.createElement('code-flask');
  188. el.attachShadow({ mode: 'open' });
  189. el.shadowRoot.innerHTML = `
  190. <style>
  191. .codeflask .codeflask__flatten { font-size: 15px; }
  192. .codeflask .codeflask__lines { background: #fafafa; z-index: 10; }
  193. .codeflask .token.tag { font-weight: bold; }
  194. .codeflask .token.attr-name { color: #111; }
  195. .codeflask .token.attr-value { color: ${colors.primary} !important; }
  196. </style>
  197. <div id="area"></area>
  198. `;
  199. this.$refs.htmlEditor.appendChild(el);
  200. this.flask = new CodeFlask(el.shadowRoot.getElementById('area'), {
  201. language: 'html',
  202. lineNumbers: false,
  203. styleParent: el.shadowRoot,
  204. readonly: this.disabled,
  205. });
  206. this.flask.onUpdate((b) => {
  207. this.form.body = b;
  208. this.$emit('input', { contentType: this.form.format, body: this.form.body });
  209. });
  210. this.updateHTMLEditor();
  211. this.isReady = true;
  212. },
  213. updateHTMLEditor() {
  214. this.flask.updateCode(this.form.body);
  215. },
  216. onFormatChange(format) {
  217. this.$utils.confirm(
  218. this.$t('campaigns.confirmSwitchFormat'),
  219. () => {
  220. this.form.format = format;
  221. this.onEditorChange();
  222. },
  223. () => {
  224. // On cancel, undo the radio selection.
  225. this.form.radioFormat = this.form.format;
  226. },
  227. );
  228. },
  229. onEditorChange() {
  230. if (!this.isReady) {
  231. return;
  232. }
  233. // The parent's v-model gets { contentType, body }.
  234. this.$emit('input', { contentType: this.form.format, body: this.form.body });
  235. },
  236. onTogglePreview() {
  237. this.isPreviewing = !this.isPreviewing;
  238. },
  239. onMediaSelect(media) {
  240. this.runTinyMceImageCallback(media.url);
  241. },
  242. beautifyHTML(str) {
  243. // Pad all tags with linebreaks.
  244. let s = this.trimLines(str.replace(/(<([^>]+)>)/ig, '\n$1\n'), true);
  245. // Remove extra linebreaks.
  246. s = s.replace(/\n+/g, '\n');
  247. return indent.html(s, { tabString: ' ' }).trim();
  248. },
  249. formatHTMLNode(node, level) {
  250. const lvl = level + 1;
  251. const indentBefore = new Array(lvl + 1).join(' ');
  252. const indentAfter = new Array(lvl - 1).join(' ');
  253. let textNode = null;
  254. for (let i = 0; i < node.children.length; i += 1) {
  255. textNode = document.createTextNode(`\n${indentBefore}`);
  256. node.insertBefore(textNode, node.children[i]);
  257. this.formatHTMLNode(node.children[i], lvl);
  258. if (node.lastElementChild === node.children[i]) {
  259. textNode = document.createTextNode(`\n${indentAfter}`);
  260. node.appendChild(textNode);
  261. }
  262. }
  263. return node;
  264. },
  265. trimLines(str, removeEmptyLines) {
  266. const out = str.split('\n');
  267. for (let i = 0; i < out.length; i += 1) {
  268. const line = out[i].trim();
  269. if (removeEmptyLines) {
  270. out[i] = line;
  271. } else if (line === '') {
  272. out[i] = '';
  273. }
  274. }
  275. return out.join('\n').replace(/\n\s*\n\s*\n/g, '\n\n');
  276. },
  277. },
  278. mounted() {
  279. this.initRichtextEditor();
  280. },
  281. computed: {
  282. ...mapState(['serverConfig']),
  283. htmlFormat() {
  284. return this.form.format;
  285. },
  286. },
  287. watch: {
  288. // Capture contentType and body passed from the parent as props.
  289. contentType(f) {
  290. this.form.format = f;
  291. this.form.radioFormat = f;
  292. if (f === 'plain' || f === 'markdown') {
  293. this.isReady = true;
  294. }
  295. // Trigger the change event so that the body and content type
  296. // are propagated to the parent on first load.
  297. this.onEditorChange();
  298. },
  299. body(b) {
  300. this.form.body = b;
  301. this.onEditorChange();
  302. },
  303. // eslint-disable-next-line func-names
  304. 'form.body': function () {
  305. this.onEditorChange();
  306. },
  307. htmlFormat(to, from) {
  308. // On switch to HTML, initialize the HTML editor.
  309. if (to === 'html') {
  310. this.$nextTick(() => {
  311. this.initHTMLEditor();
  312. });
  313. }
  314. if ((from === 'richtext' || from === 'html') && to === 'plain') {
  315. // richtext, html => plain
  316. // Preserve line breaks when converting HTML to plaintext.
  317. const d = document.createElement('div');
  318. d.innerHTML = this.beautifyHTML(this.form.body);
  319. this.form.body = this.trimLines(d.innerText.trim(), true);
  320. } else if ((from === 'richtext' || from === 'html') && to === 'markdown') {
  321. // richtext, html => markdown
  322. this.form.body = turndown.turndown(this.form.body).replace(/\n\n+/ig, '\n\n');
  323. } else if (from === 'plain' && (to === 'richtext' || to === 'html')) {
  324. // plain => richtext, html
  325. this.form.body = this.form.body.replace(/\n/ig, '<br>\n');
  326. } else if (from === 'richtext' && to === 'html') {
  327. // richtext => html
  328. this.form.body = this.beautifyHTML(this.form.body);
  329. } else if (from === 'markdown' && (to === 'richtext' || to === 'html')) {
  330. // markdown => richtext, html.
  331. this.$api.convertCampaignContent({
  332. id: 1, body: this.form.body, from, to,
  333. }).then((data) => {
  334. this.form.body = this.beautifyHTML(data.trim());
  335. // Update the HTML editor.
  336. if (to === 'html') {
  337. this.updateHTMLEditor();
  338. }
  339. });
  340. }
  341. this.onEditorChange();
  342. },
  343. },
  344. };
  345. </script>