Editor.vue 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473
  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" data-cy="btn-preview">
  30. {{ $t('campaigns.preview') }}
  31. </b-button>
  32. </div>
  33. </div>
  34. <!-- wsywig //-->
  35. <template v-if="isRichtextReady && form.format === 'richtext'">
  36. <tiny-mce
  37. v-model="form.body"
  38. :disabled="disabled"
  39. :init="richtextConf"
  40. />
  41. <b-modal scroll="keep" :width="1200"
  42. :aria-modal="true" :active.sync="isRichtextSourceVisible">
  43. <div>
  44. <section expanded class="modal-card-body preview">
  45. <html-editor v-model="richTextSourceBody" />
  46. </section>
  47. <footer class="modal-card-foot has-text-right">
  48. <b-button @click="onFormatRichtextHTML">{{ $t('campaigns.formatHTML') }}</b-button>
  49. <b-button @click="() => { this.isRichtextSourceVisible = false; }">
  50. {{ $t('globals.buttons.close') }}
  51. </b-button>
  52. <b-button @click="onSaveRichTextSource" class="is-primary">
  53. {{ $t('globals.buttons.save') }}
  54. </b-button>
  55. </footer>
  56. </div>
  57. </b-modal>
  58. </template>
  59. <!-- raw html editor //-->
  60. <html-editor v-if="form.format === 'html'" v-model="form.body" />
  61. <!-- plain text / markdown editor //-->
  62. <b-input v-if="form.format === 'plain' || form.format === 'markdown'"
  63. v-model="form.body" @input="onEditorChange"
  64. type="textarea" name="content" ref="plainEditor" class="plain-editor" />
  65. <!-- campaign preview //-->
  66. <campaign-preview v-if="isPreviewing"
  67. @close="onTogglePreview"
  68. type="campaign"
  69. :id="id"
  70. :title="title"
  71. :contentType="form.format"
  72. :templateId="templateId"
  73. :body="form.body"></campaign-preview>
  74. <!-- image picker -->
  75. <b-modal scroll="keep" :aria-modal="true" :active.sync="isMediaVisible" :width="900">
  76. <div class="modal-card content" style="width: auto">
  77. <section expanded class="modal-card-body">
  78. <media isModal @selected="onMediaSelect" />
  79. </section>
  80. </div>
  81. </b-modal>
  82. </section>
  83. </template>
  84. <script>
  85. import { mapState } from 'vuex';
  86. import TurndownService from 'turndown';
  87. import { indent } from 'indent.js';
  88. import 'tinymce';
  89. import 'tinymce/icons/default';
  90. import 'tinymce/themes/silver';
  91. import 'tinymce/skins/ui/oxide/skin.css';
  92. import 'tinymce/plugins/autoresize';
  93. import 'tinymce/plugins/autolink';
  94. import 'tinymce/plugins/charmap';
  95. import 'tinymce/plugins/colorpicker';
  96. import 'tinymce/plugins/contextmenu';
  97. import 'tinymce/plugins/emoticons';
  98. import 'tinymce/plugins/emoticons/js/emojis';
  99. import 'tinymce/plugins/fullscreen';
  100. import 'tinymce/plugins/help';
  101. import 'tinymce/plugins/hr';
  102. import 'tinymce/plugins/image';
  103. import 'tinymce/plugins/imagetools';
  104. import 'tinymce/plugins/link';
  105. import 'tinymce/plugins/lists';
  106. import 'tinymce/plugins/paste';
  107. import 'tinymce/plugins/searchreplace';
  108. import 'tinymce/plugins/table';
  109. import 'tinymce/plugins/textcolor';
  110. import 'tinymce/plugins/visualblocks';
  111. import 'tinymce/plugins/visualchars';
  112. import 'tinymce/plugins/wordcount';
  113. import TinyMce from '@tinymce/tinymce-vue';
  114. import CampaignPreview from './CampaignPreview.vue';
  115. import HTMLEditor from './HTMLEditor.vue';
  116. import Media from '../views/Media.vue';
  117. import { colors, uris } from '../constants';
  118. const turndown = new TurndownService();
  119. // Map of listmonk language codes to corresponding TinyMCE language files.
  120. const LANGS = {
  121. 'cs-cz': 'cs',
  122. de: 'de',
  123. es: 'es_419',
  124. fr: 'fr_FR',
  125. it: 'it_IT',
  126. pl: 'pl',
  127. pt: 'pt_PT',
  128. 'pt-BR': 'pt_BR',
  129. ro: 'ro',
  130. tr: 'tr',
  131. };
  132. export default {
  133. components: {
  134. Media,
  135. CampaignPreview,
  136. 'html-editor': HTMLEditor,
  137. TinyMce,
  138. },
  139. props: {
  140. id: Number,
  141. title: String,
  142. body: String,
  143. contentType: String,
  144. templateId: {
  145. type: Number,
  146. default: 0,
  147. },
  148. disabled: Boolean,
  149. },
  150. data() {
  151. return {
  152. isPreviewing: false,
  153. isMediaVisible: false,
  154. isEditorFullscreen: false,
  155. isReady: false,
  156. isRichtextReady: false,
  157. isRichtextSourceVisible: false,
  158. richtextConf: {},
  159. isTrackLink: false,
  160. richTextSourceBody: '',
  161. form: {
  162. body: '',
  163. format: this.contentType,
  164. // Model bound to the checkboxes. This changes on click of the radio,
  165. // but is reverted by the change handler if the user cancels the
  166. // conversion warning. This is used to set the value of form.format
  167. // that the editor uses to render content.
  168. radioFormat: this.contentType,
  169. },
  170. // Last position of the cursor in the editor before the media popup
  171. // was opened. This is used to insert media on selection from the poup
  172. // where the caret may be lost.
  173. lastSel: null,
  174. };
  175. },
  176. methods: {
  177. initRichtextEditor() {
  178. const { lang } = this.serverConfig;
  179. this.richtextConf = {
  180. init_instance_callback: () => { this.isReady = true; },
  181. urlconverter_callback: this.onEditorURLConvert,
  182. setup: (editor) => {
  183. editor.on('init', () => {
  184. this.onEditorDialogOpen(editor);
  185. });
  186. // Custom HTML editor.
  187. editor.ui.registry.addButton('html', {
  188. icon: 'sourcecode',
  189. tooltip: 'Source code',
  190. onAction: this.onRichtextViewSource,
  191. });
  192. },
  193. min_height: 500,
  194. toolbar_sticky: true,
  195. entity_encoding: 'raw',
  196. convert_urls: true,
  197. plugins: [
  198. 'autoresize', 'autolink', 'charmap', 'emoticons', 'fullscreen', 'help',
  199. 'hr', 'image', 'imagetools', 'link', 'lists', 'paste', 'searchreplace',
  200. 'table', 'visualblocks', 'visualchars', 'wordcount',
  201. ],
  202. toolbar: `undo redo | formatselect styleselect fontsizeselect |
  203. bold italic underline strikethrough forecolor backcolor subscript superscript |
  204. alignleft aligncenter alignright alignjustify |
  205. bullist numlist table image | outdent indent | link hr removeformat |
  206. html fullscreen help`,
  207. fontsize_formats: '10px 11px 12px 14px 15px 16px 18px 24px 36px',
  208. skin: false,
  209. content_css: false,
  210. content_style: `
  211. body { font-family: 'Inter', sans-serif; font-size: 15px; }
  212. img { max-width: 100%; }
  213. a { color: ${colors.primary}; }
  214. table, td { border-color: #ccc;}
  215. `,
  216. language: LANGS[lang] || null,
  217. language_url: LANGS[lang] ? `${uris.static}/tinymce/lang/${LANGS[lang]}.js` : null,
  218. file_picker_types: 'image',
  219. file_picker_callback: (callback) => {
  220. this.isMediaVisible = true;
  221. this.runTinyMceImageCallback = callback;
  222. },
  223. };
  224. this.isRichtextReady = true;
  225. },
  226. onFormatChange(format) {
  227. if (this.form.body.trim() === '') {
  228. this.form.format = format;
  229. this.onEditorChange();
  230. return;
  231. }
  232. // Content isn't empty. Warn.
  233. this.$utils.confirm(
  234. this.$t('campaigns.confirmSwitchFormat'),
  235. () => {
  236. this.form.format = format;
  237. this.onEditorChange();
  238. },
  239. () => {
  240. // On cancel, undo the radio selection.
  241. this.form.radioFormat = this.form.format;
  242. },
  243. );
  244. },
  245. onEditorURLConvert(url) {
  246. let u = url;
  247. if (this.isTrackLink) {
  248. u = `${u}@TrackLink`;
  249. }
  250. this.isTrackLink = false;
  251. return u;
  252. },
  253. onRichtextViewSource() {
  254. this.richTextSourceBody = this.form.body;
  255. this.isRichtextSourceVisible = true;
  256. },
  257. onFormatRichtextHTML() {
  258. this.richTextSourceBody = this.beautifyHTML(this.richTextSourceBody);
  259. },
  260. onSaveRichTextSource() {
  261. this.form.body = this.richTextSourceBody;
  262. window.tinymce.editors[0].setContent(this.form.body);
  263. this.richTextSourceBody = '';
  264. this.isRichtextSourceVisible = false;
  265. },
  266. onEditorDialogOpen(editor) {
  267. const ed = editor;
  268. const oldEd = ed.windowManager.open;
  269. const self = this;
  270. ed.windowManager.open = (t, r) => {
  271. const isOK = t.initialData && 'url' in t.initialData && 'anchor' in t.initialData;
  272. // Not the link modal.
  273. if (!isOK) {
  274. return oldEd.apply(this, [t, r]);
  275. }
  276. // If an existing link is being edited, check for the tracking flag `@TrackLink` at the end
  277. // of the url. Remove that from the URL and instead check the checkbox.
  278. let checked = false;
  279. if (!t.initialData.link !== '') {
  280. const t2 = t;
  281. const url = t2.initialData.url.value.replace(/@TrackLink$/, '');
  282. if (t2.initialData.url.value !== url) {
  283. t2.initialData.url.value = url;
  284. checked = true;
  285. }
  286. }
  287. // Execute the modal.
  288. const modal = oldEd.apply(this, [t, r]);
  289. // Is it the link dialog?
  290. if (isOK) {
  291. // Insert tracking checkbox.
  292. const c = document.createElement('input');
  293. c.setAttribute('type', 'checkbox');
  294. if (checked) {
  295. c.setAttribute('checked', checked);
  296. }
  297. // Store the checkbox's state in the Vue instance to pick up from
  298. // the TinyMCE link conversion callback.
  299. c.onchange = (e) => {
  300. self.isTrackLink = e.target.checked;
  301. };
  302. const l = document.createElement('label');
  303. l.appendChild(c);
  304. l.appendChild(document.createTextNode('Track link?'));
  305. l.classList.add('tox-label', 'tox-track-link');
  306. document.querySelector('.tox-form__controls-h-stack .tox-control-wrap').appendChild(l);
  307. }
  308. return modal;
  309. };
  310. },
  311. onEditorChange() {
  312. if (!this.isReady) {
  313. return;
  314. }
  315. // The parent's v-model gets { contentType, body }.
  316. this.$emit('input', { contentType: this.form.format, body: this.form.body });
  317. },
  318. onTogglePreview() {
  319. this.isPreviewing = !this.isPreviewing;
  320. },
  321. onMediaSelect(media) {
  322. this.runTinyMceImageCallback(media.url);
  323. },
  324. beautifyHTML(str) {
  325. // Pad all tags with linebreaks.
  326. let s = this.trimLines(str.replace(/(<([^>]+)>)/ig, '\n$1\n'), true);
  327. // Remove extra linebreaks.
  328. s = s.replace(/\n+/g, '\n');
  329. return indent.html(s, { tabString: ' ' }).trim();
  330. },
  331. trimLines(str, removeEmptyLines) {
  332. const out = str.split('\n');
  333. for (let i = 0; i < out.length; i += 1) {
  334. const line = out[i].trim();
  335. if (removeEmptyLines) {
  336. out[i] = line;
  337. } else if (line === '') {
  338. out[i] = '';
  339. }
  340. }
  341. return out.join('\n').replace(/\n\s*\n\s*\n/g, '\n\n');
  342. },
  343. },
  344. mounted() {
  345. this.initRichtextEditor();
  346. },
  347. computed: {
  348. ...mapState(['serverConfig']),
  349. htmlFormat() {
  350. return this.form.format;
  351. },
  352. },
  353. watch: {
  354. // Capture contentType and body passed from the parent as props.
  355. contentType(f) {
  356. this.form.format = f;
  357. this.form.radioFormat = f;
  358. if (f !== 'richtext') {
  359. this.isReady = true;
  360. }
  361. // Trigger the change event so that the body and content type
  362. // are propagated to the parent on first load.
  363. this.onEditorChange();
  364. },
  365. body(b) {
  366. this.form.body = b;
  367. this.onEditorChange();
  368. },
  369. // eslint-disable-next-line func-names
  370. 'form.body': function () {
  371. this.onEditorChange();
  372. },
  373. htmlFormat(to, from) {
  374. if ((from === 'richtext' || from === 'html') && to === 'plain') {
  375. // richtext, html => plain
  376. // Preserve line breaks when converting HTML to plaintext.
  377. const d = document.createElement('div');
  378. d.innerHTML = this.beautifyHTML(this.form.body);
  379. this.$nextTick(() => {
  380. this.form.body = this.trimLines(d.innerText.trim(), true);
  381. });
  382. } else if ((from === 'richtext' || from === 'html') && to === 'markdown') {
  383. // richtext, html => markdown
  384. this.form.body = turndown.turndown(this.form.body).replace(/\n\n+/ig, '\n\n');
  385. } else if (from === 'plain' && (to === 'richtext' || to === 'html')) {
  386. // plain => richtext, html
  387. this.form.body = this.form.body.replace(/\n/ig, '<br>\n');
  388. } else if (from === 'richtext' && to === 'html') {
  389. // richtext => html
  390. this.form.body = this.beautifyHTML(this.form.body);
  391. } else if (from === 'markdown' && (to === 'richtext' || to === 'html')) {
  392. // markdown => richtext, html.
  393. this.$api.convertCampaignContent({
  394. id: 1, body: this.form.body, from, to,
  395. }).then((data) => {
  396. this.form.body = this.beautifyHTML(data.trim());
  397. // Update the HTML editor.
  398. if (to === 'html') {
  399. this.updateHTMLEditor();
  400. }
  401. });
  402. }
  403. this.onEditorChange();
  404. },
  405. },
  406. };
  407. </script>