@@ -0,0 +1,400 @@
|
||||
<template>
|
||||
<div class="richtext-editor" v-if="isRichtextReady">
|
||||
<tiny-mce v-model="computedValue" :disabled="disabled" :init="richtextConf" />
|
||||
|
||||
<b-modal scroll="keep" :width="1200" :aria-modal="true" :active.sync="isRichtextSourceVisible">
|
||||
<div>
|
||||
<section expanded class="modal-card-body preview">
|
||||
<code-editor lang="html" v-model="richTextSourceBody" key="richtext-source" />
|
||||
</section>
|
||||
<footer class="modal-card-foot has-text-right">
|
||||
<b-button @click="onFormatRichtextHTML">
|
||||
{{ $t('campaigns.formatHTML') }}
|
||||
</b-button>
|
||||
<b-button @click="() => { this.isRichtextSourceVisible = false; }">
|
||||
{{ $t('globals.buttons.close') }}
|
||||
</b-button>
|
||||
<b-button @click="onSaveRichTextSource" class="is-primary">
|
||||
{{ $t('globals.buttons.save') }}
|
||||
</b-button>
|
||||
</footer>
|
||||
</div>
|
||||
</b-modal>
|
||||
|
||||
<b-modal scroll="keep" :width="750" :aria-modal="true" :active.sync="isInsertHTMLVisible">
|
||||
<div>
|
||||
<section expanded class="modal-card-body preview">
|
||||
<code-editor lang="html" v-model="insertHTMLSnippet" key="richtext-snippet" />
|
||||
</section>
|
||||
<footer class="modal-card-foot has-text-right">
|
||||
<b-button @click="onFormatRichtextHTMLSnippet">
|
||||
{{ $t('campaigns.formatHTML') }}
|
||||
</b-button>
|
||||
<b-button @click="() => { this.isInsertHTMLVisible = false; }">
|
||||
{{ $t('globals.buttons.close') }}
|
||||
</b-button>
|
||||
<b-button @click="onInsertHTML" class="is-primary">
|
||||
{{ $t('globals.buttons.insert') }}
|
||||
</b-button>
|
||||
</footer>
|
||||
</div>
|
||||
</b-modal>
|
||||
|
||||
<!-- image picker -->
|
||||
<b-modal scroll="keep" :aria-modal="true" :active.sync="isMediaVisible" :width="900">
|
||||
<div class="modal-card content" style="width: auto">
|
||||
<section expanded class="modal-card-body">
|
||||
<media is-modal @selected="onMediaSelect" />
|
||||
</section>
|
||||
</div>
|
||||
</b-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { html } from 'js-beautify';
|
||||
import { mapState } from 'vuex';
|
||||
|
||||
import TinyMce from '@tinymce/tinymce-vue';
|
||||
import 'tinymce';
|
||||
import 'tinymce/icons/default';
|
||||
import 'tinymce/plugins/anchor';
|
||||
import 'tinymce/plugins/autolink';
|
||||
import 'tinymce/plugins/autoresize';
|
||||
import 'tinymce/plugins/charmap';
|
||||
import 'tinymce/plugins/colorpicker';
|
||||
import 'tinymce/plugins/contextmenu';
|
||||
import 'tinymce/plugins/emoticons';
|
||||
import 'tinymce/plugins/emoticons/js/emojis';
|
||||
import 'tinymce/plugins/fullscreen';
|
||||
import 'tinymce/plugins/help';
|
||||
import 'tinymce/plugins/hr';
|
||||
import 'tinymce/plugins/image';
|
||||
import 'tinymce/plugins/imagetools';
|
||||
import 'tinymce/plugins/link';
|
||||
import 'tinymce/plugins/lists';
|
||||
import 'tinymce/plugins/paste';
|
||||
import 'tinymce/plugins/searchreplace';
|
||||
import 'tinymce/plugins/table';
|
||||
import 'tinymce/plugins/textcolor';
|
||||
import 'tinymce/plugins/visualblocks';
|
||||
import 'tinymce/plugins/visualchars';
|
||||
import 'tinymce/plugins/wordcount';
|
||||
import 'tinymce/skins/ui/oxide/skin.css';
|
||||
import 'tinymce/themes/silver';
|
||||
|
||||
import { colors, uris } from '../constants';
|
||||
import Media from '../views/Media.vue';
|
||||
import CodeEditor from './CodeEditor.vue';
|
||||
|
||||
// Map of eaglecast language codes to corresponding TinyMCE language files.
|
||||
const LANGS = {
|
||||
cs: 'cs',
|
||||
de: 'de',
|
||||
es: 'es_419',
|
||||
fr: 'fr_FR',
|
||||
it: 'it_IT',
|
||||
pl: 'pl',
|
||||
pt: 'pt_PT',
|
||||
'pt-BR': 'pt_BR',
|
||||
ro: 'ro',
|
||||
tr: 'tr',
|
||||
};
|
||||
|
||||
const TRACK_LINK = 'trackLink';
|
||||
const TRACK_SUFFIX = '@TrackLink';
|
||||
const EMBED_IMAGE = 'embedImage';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Media,
|
||||
'tiny-mce': TinyMce,
|
||||
'code-editor': CodeEditor,
|
||||
},
|
||||
|
||||
props: {
|
||||
disabled: { type: Boolean, default: false },
|
||||
value: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
isPreviewing: false,
|
||||
isMediaVisible: false,
|
||||
isReady: false,
|
||||
isRichtextReady: false,
|
||||
isRichtextSourceVisible: false,
|
||||
isInsertHTMLVisible: false,
|
||||
insertHTMLSnippet: '',
|
||||
richtextConf: {},
|
||||
richTextSourceBody: '',
|
||||
contentType: '',
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
initRichtextEditor() {
|
||||
const { lang } = this.serverConfig;
|
||||
|
||||
this.richtextConf = {
|
||||
init_instance_callback: () => { this.isReady = true; },
|
||||
urlconverter_callback: this.onEditorURLConvert,
|
||||
|
||||
setup: (editor) => {
|
||||
editor.addShortcut('ctrl+s', 'Save content', () => {
|
||||
this.$events.$emit('campaign.update', {});
|
||||
});
|
||||
editor.addShortcut('f9', 'Preview', () => {
|
||||
this.$events.$emit('campaign.preview', {});
|
||||
});
|
||||
|
||||
editor.on('init', () => {
|
||||
editor.focus();
|
||||
this.onEditorDialogOpen(editor);
|
||||
});
|
||||
|
||||
// Custom HTML editor.
|
||||
editor.ui.registry.addButton('html', {
|
||||
icon: 'sourcecode',
|
||||
tooltip: 'Source code',
|
||||
onAction: this.onRichtextViewSource,
|
||||
});
|
||||
|
||||
editor.ui.registry.addButton('insert-html', {
|
||||
icon: 'code-sample',
|
||||
tooltip: 'Insert HTML',
|
||||
onAction: this.onOpenInsertHTML,
|
||||
});
|
||||
|
||||
editor.on('CloseWindow', () => {
|
||||
editor.selection.getNode().scrollIntoView(false);
|
||||
});
|
||||
},
|
||||
|
||||
browser_spellcheck: true,
|
||||
min_height: 500,
|
||||
toolbar_sticky: true,
|
||||
entity_encoding: 'raw',
|
||||
convert_urls: true,
|
||||
relative_urls: false,
|
||||
remove_script_host: false,
|
||||
extended_valid_elements: 'img[*]',
|
||||
plugins: [
|
||||
'anchor', 'autoresize', 'autolink', 'charmap', 'emoticons', 'fullscreen',
|
||||
'help', 'hr', 'image', 'imagetools', 'link', 'lists', 'paste', 'searchreplace',
|
||||
'table', 'visualblocks', 'visualchars', 'wordcount',
|
||||
],
|
||||
toolbar: `undo redo | formatselect styleselect fontsizeselect |
|
||||
bold italic underline strikethrough forecolor backcolor subscript superscript |
|
||||
alignleft aligncenter alignright alignjustify |
|
||||
bullist numlist table image insert-html | outdent indent | link hr removeformat |
|
||||
html fullscreen help`,
|
||||
fontsize_formats: '10px 11px 12px 14px 15px 16px 18px 24px 36px',
|
||||
skin: false,
|
||||
content_css: false,
|
||||
content_style: `
|
||||
body { font-family: 'Inter', sans-serif; font-size: 15px; }
|
||||
img { max-width: 100%; }
|
||||
img.img-float-left { float: left; margin: 0 1em 1em 0; }
|
||||
img.img-float-right { float: right; margin: 0 0 1em 1em; }
|
||||
a { color: ${colors.primary}; }
|
||||
table, td { border-color: #ccc;}
|
||||
`,
|
||||
|
||||
language: LANGS[lang] || null,
|
||||
language_url: LANGS[lang] ? `${uris.static}/tinymce/lang/${LANGS[lang]}.js` : null,
|
||||
|
||||
image_advtab: true,
|
||||
image_class_list: [
|
||||
{ title: 'None', value: '' },
|
||||
{ title: 'Float left', value: 'img-float-left' },
|
||||
{ title: 'Float right', value: 'img-float-right' },
|
||||
],
|
||||
|
||||
file_picker_types: 'image',
|
||||
file_picker_callback: (callback) => {
|
||||
this.isMediaVisible = true;
|
||||
this.imageCallack = callback;
|
||||
},
|
||||
};
|
||||
|
||||
this.isRichtextReady = true;
|
||||
},
|
||||
|
||||
onEditorURLConvert(url) {
|
||||
return url;
|
||||
},
|
||||
|
||||
onRichtextViewSource() {
|
||||
this.richTextSourceBody = this.computedValue;
|
||||
this.isRichtextSourceVisible = true;
|
||||
},
|
||||
|
||||
onOpenInsertHTML() {
|
||||
this.isInsertHTMLVisible = true;
|
||||
},
|
||||
|
||||
onInsertHTML() {
|
||||
this.isInsertHTMLVisible = false;
|
||||
window.tinymce.editors[0].execCommand('mceInsertContent', false, this.insertHTMLSnippet);
|
||||
this.insertHTMLSnippet = '';
|
||||
},
|
||||
|
||||
onFormatRichtextHTML() {
|
||||
this.richTextSourceBody = this.beautifyHTML(this.richTextSourceBody);
|
||||
},
|
||||
|
||||
onFormatRichtextHTMLSnippet() {
|
||||
this.insertHTMLSnippet = this.beautifyHTML(this.insertHTMLSnippet);
|
||||
},
|
||||
|
||||
onSaveRichTextSource() {
|
||||
this.computedValue = this.richTextSourceBody;
|
||||
window.tinymce.editors[0].setContent(this.computedValue);
|
||||
this.richTextSourceBody = '';
|
||||
this.isRichtextSourceVisible = false;
|
||||
},
|
||||
|
||||
onEditorDialogOpen(editor) {
|
||||
const ed = editor;
|
||||
const oldEd = ed.windowManager.open;
|
||||
|
||||
ed.windowManager.open = (t, r) => {
|
||||
const data = t.initialData || {};
|
||||
const isLink = data.url && 'anchor' in data;
|
||||
const isImage = data.src && !isLink;
|
||||
|
||||
if (!isLink && !isImage) {
|
||||
return oldEd.call(ed.windowManager, t, r);
|
||||
}
|
||||
|
||||
const { onSubmit } = t;
|
||||
const checkbox = isLink ? { type: 'checkbox', name: TRACK_LINK, label: 'Track link?' } : { type: 'checkbox', name: EMBED_IMAGE, label: this.$t('media.embed') };
|
||||
const spec = { ...t, body: this.withDialogCheckbox(t.body, checkbox) };
|
||||
|
||||
if (isLink) {
|
||||
const cleanURL = (data.url.value || '').replace(/@TrackLink$/, '');
|
||||
const checked = data.url.value !== cleanURL
|
||||
|| (!cleanURL && JSON.parse(localStorage.getItem(TRACK_LINK) || 'false'));
|
||||
spec.initialData = { ...data, [TRACK_LINK]: checked, url: { ...data.url, value: cleanURL } };
|
||||
spec.onSubmit = (api) => {
|
||||
const d = api.getData();
|
||||
const shouldTrack = Boolean(d[TRACK_LINK]);
|
||||
const url = (d.url.value || '').replace(/@TrackLink$/, '');
|
||||
localStorage.setItem(TRACK_LINK, JSON.stringify(shouldTrack));
|
||||
if (shouldTrack && /^https?:\/\//i.test(url)) {
|
||||
api.setData({ url: { ...d.url, value: `${url}${TRACK_SUFFIX}` } });
|
||||
}
|
||||
onSubmit(api);
|
||||
};
|
||||
} else {
|
||||
const img = this.getSelectedImage(ed);
|
||||
spec.initialData = { ...data, [EMBED_IMAGE]: Boolean(img && img.hasAttribute('data-embed')) };
|
||||
spec.onSubmit = (api) => {
|
||||
const d = api.getData();
|
||||
const shouldEmbed = d[EMBED_IMAGE] === true || d[EMBED_IMAGE] === 'true';
|
||||
|
||||
onSubmit(api);
|
||||
|
||||
// Apply 'embed' attr.
|
||||
const node = (img && ed.getBody().contains(img)) ? img : this.getSelectedImage(ed);
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
if (shouldEmbed) {
|
||||
ed.dom.setAttrib(node, 'data-embed', 'true');
|
||||
} else {
|
||||
node.removeAttribute('data-embed');
|
||||
}
|
||||
ed.fire('change');
|
||||
ed.save();
|
||||
this.computedValue = ed.getContent();
|
||||
};
|
||||
}
|
||||
|
||||
return oldEd.call(ed.windowManager, spec, r);
|
||||
};
|
||||
},
|
||||
|
||||
withDialogCheckbox(body, checkbox) {
|
||||
if (body.type === 'tabpanel') {
|
||||
return {
|
||||
...body,
|
||||
tabs: body.tabs.map((tab) => (
|
||||
tab.name === 'general' || tab.title === 'General'
|
||||
? { ...tab, items: [...tab.items, checkbox] }
|
||||
: tab
|
||||
)),
|
||||
};
|
||||
}
|
||||
return { ...body, items: [...body.items, checkbox] };
|
||||
},
|
||||
|
||||
getSelectedImage(editor) {
|
||||
const node = editor.selection.getNode();
|
||||
if (!node) {
|
||||
return null;
|
||||
}
|
||||
if (node.nodeName === 'IMG') {
|
||||
return node;
|
||||
}
|
||||
const figure = editor.dom.getParent(node, 'figure.image');
|
||||
return figure ? figure.querySelector('img') : null;
|
||||
},
|
||||
|
||||
onMediaSelect(media) {
|
||||
this.imageCallack(media.url);
|
||||
},
|
||||
|
||||
beautifyHTML(str) {
|
||||
// Pad all tags with linebreaks.
|
||||
let s = this.trimLines(str.replace(/(<(?!(\/)?a|span)([^>]+)>)/gi, '\n$1\n'), true);
|
||||
// Remove extra linebreaks.
|
||||
s = s.replace(/\n+/g, '\n');
|
||||
|
||||
try {
|
||||
s = html(s).trim();
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('error formatting HTML', error);
|
||||
}
|
||||
|
||||
return s;
|
||||
},
|
||||
|
||||
trimLines(str, removeEmptyLines) {
|
||||
const out = str.split('\n');
|
||||
for (let i = 0; i < out.length; i += 1) {
|
||||
const line = out[i].trim();
|
||||
if (removeEmptyLines) {
|
||||
out[i] = line;
|
||||
} else if (line === '') {
|
||||
out[i] = '';
|
||||
}
|
||||
}
|
||||
|
||||
return out.join('\n').replace(/\n\s*\n\s*\n/g, '\n\n');
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.initRichtextEditor();
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapState(['serverConfig']),
|
||||
|
||||
computedValue: {
|
||||
get() {
|
||||
return this.value;
|
||||
},
|
||||
set(newValue) {
|
||||
this.$emit('input', newValue);
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
Reference in New Issue
Block a user