Add custom modules to WYSIWYG (#4309)

* use drawers on wysiwyg

* fix media duplication and add translations

* remove console logs

* remove old media buttons

* organize image/media modules

* add link and code windows

Co-authored-by: rijkvanzanten <rijkvanzanten@me.com>
This commit is contained in:
Nitwel
2021-04-10 01:57:49 +02:00
committed by GitHub
parent c4e1e40279
commit d50f3f9edb
7 changed files with 586 additions and 58 deletions

View File

@@ -66,11 +66,11 @@
font-feature-settings: 'liga';
}
.tox-tbtn[aria-label='Insert/edit link'] .tox-icon svg {
.tox-tbtn[aria-label='Add/Edit Link'] .tox-icon svg {
display: none;
}
.tox-tbtn[aria-label='Insert/edit link'] .tox-icon::after {
.tox-tbtn[aria-label='Add/Edit Link'] .tox-icon::after {
display: inline-block;
margin-top: 4px;
color: var(--foreground-normal);
@@ -278,8 +278,8 @@ body.dark .tox .tox-toolbar__overflow {
.tox .tox-button {
display: flex;
align-items: center;
justify-content: center;
align-items: center;
min-width: 136px;
height: 44px;
padding: 0 20px 1px;

View File

@@ -0,0 +1,80 @@
import { Ref, ref } from '@vue/composition-api';
import { getPublicURL } from '@/utils/get-root-path';
import { addTokenToURL } from '@/api';
import i18n from '@/lang';
type ImageSelection = {
imageUrl: string;
alt: string;
width?: number;
height?: number;
};
export default function useImage(editor: Ref<any>, imageToken: Ref<string>) {
const imageDrawerOpen = ref(false);
const imageSelection = ref<ImageSelection | null>(null);
const imageButton = {
icon: 'image',
tooltip: i18n.t('wysiwyg_options.image'),
onAction: (buttonApi: any) => {
imageDrawerOpen.value = true;
if (buttonApi.isActive()) {
const node = editor.value.selection.getNode() as HTMLImageElement;
const imageUrl = node.getAttribute('src');
const alt = node.getAttribute('alt');
if (imageUrl === null || alt === null) {
return;
}
imageSelection.value = {
imageUrl,
alt,
width: Number(node.getAttribute('width')) || undefined,
height: Number(node.getAttribute('height')) || undefined,
};
} else {
imageSelection.value = null;
}
},
onSetup: (buttonApi: any) => {
const onImageNodeSelect = (eventApi: any) => {
buttonApi.setActive(eventApi.element.tagName === 'IMG');
};
editor.value.on('NodeChange', onImageNodeSelect);
return function (buttonApi: any) {
editor.value.off('NodeChange', onImageNodeSelect);
};
},
};
return { imageDrawerOpen, imageSelection, closeImageDrawer, onImageSelect, saveImage, imageButton };
function closeImageDrawer() {
imageSelection.value = null;
imageDrawerOpen.value = false;
}
function onImageSelect(image: Record<string, any>) {
const imageUrl = addTokenToURL(getPublicURL() + 'assets/' + image.id, imageToken.value);
imageSelection.value = {
imageUrl,
alt: image.title,
width: image.width,
height: image.height,
};
}
function saveImage() {
const img = imageSelection.value;
if (img === null) return;
const imageHtml = `<img src="${img.imageUrl}" alt="${img.alt}" width="${img.width}" height="${img.height}" />`;
editor.value.selection.setContent(imageHtml);
closeImageDrawer();
}
}

View File

@@ -0,0 +1,88 @@
import { Ref, ref } from '@vue/composition-api';
import i18n from '@/lang';
type LinkSelection = {
url: string | null;
displayText: string | null;
title: string | null;
newTab: boolean;
};
export default function useLink(editor: Ref<any>) {
const linkDrawerOpen = ref(false);
const linkSelection = ref<LinkSelection>({
url: null,
displayText: null,
title: null,
newTab: true,
});
const linkButton = {
icon: 'link',
tooltip: i18n.t('wysiwyg_options.link'),
onAction: (buttonApi: any) => {
linkDrawerOpen.value = true;
if (buttonApi.isActive()) {
const node = editor.value.selection.getNode() as HTMLLinkElement;
editor.value.selection.select(node);
const url = node.getAttribute('href');
const title = node.getAttribute('title');
const displayText = node.innerText;
const target = node.getAttribute('target');
if (url === null || displayText === null) {
return;
}
linkSelection.value = {
url,
displayText,
title: title || null,
newTab: target === '_blank',
};
} else {
defaultLinkSelection();
}
},
onSetup: (buttonApi: any) => {
const onImageNodeSelect = (eventApi: any) => {
buttonApi.setActive(eventApi.element.tagName === 'A');
};
editor.value.on('NodeChange', onImageNodeSelect);
return function (buttonApi: any) {
editor.value.off('NodeChange', onImageNodeSelect);
};
},
};
return { linkDrawerOpen, linkSelection, closeLinkDrawer, saveLink, linkButton };
function defaultLinkSelection() {
linkSelection.value = {
url: null,
displayText: null,
title: null,
newTab: true,
};
}
function closeLinkDrawer() {
defaultLinkSelection();
linkDrawerOpen.value = false;
}
function saveLink() {
let link = linkSelection.value;
if (link.url === null) return;
const linkHtml = `<a href="${link.url}" title="${link.title || ''}" target="${link.newTab ? '_blank' : '_self'}" >${
link.displayText || link.url
}</a>`;
editor.value.selection.setContent(linkHtml);
closeLinkDrawer();
}
}

View File

@@ -0,0 +1,152 @@
import { computed, Ref, ref, watch } from '@vue/composition-api';
import { getPublicURL } from '@/utils/get-root-path';
import { addTokenToURL } from '@/api';
import i18n from '@/lang';
type MediaSelection = {
source: string;
width?: number;
height?: number;
};
export default function useMedia(editor: Ref<any>, imageToken: Ref<string>) {
const mediaDrawerOpen = ref(false);
const mediaSelection = ref<MediaSelection | null>(null);
const openMediaTab = ref(['video']);
const embed = ref('');
const startEmbed = ref('');
const mediaButton = {
icon: 'embed',
tooltip: i18n.t('wysiwyg_options.media'),
onAction: (buttonApi: any) => {
mediaDrawerOpen.value = true;
if (buttonApi.isActive()) {
if (editor.value.selection.getContent() === null) return;
embed.value = editor.value.selection.getContent();
startEmbed.value = embed.value;
} else {
mediaSelection.value = null;
}
},
onSetup: (buttonApi: any) => {
const onVideoNodeSelect = (eventApi: any) => {
buttonApi.setActive(
eventApi.element.tagName === 'SPAN' && eventApi.element.classList.contains('mce-preview-object')
);
};
editor.value.on('NodeChange', onVideoNodeSelect);
return function (buttonApi: any) {
editor.value.off('NodeChange', onVideoNodeSelect);
};
},
};
const mediaSource = computed({
get() {
return mediaSelection.value?.source;
},
set(newSource: any) {
mediaSelection.value = { ...mediaSelection.value, source: newSource };
},
});
const mediaWidth = computed({
get() {
return mediaSelection.value?.width;
},
set(newSource: number | undefined) {
if (mediaSelection.value === null) return;
mediaSelection.value = { ...mediaSelection.value, width: newSource };
},
});
const mediaHeight = computed({
get() {
return mediaSelection.value?.height;
},
set(newSource: number | undefined) {
if (mediaSelection.value === null) return;
mediaSelection.value = { ...mediaSelection.value, height: newSource };
},
});
watch(mediaSelection, (vid) => {
if (embed.value === '') {
if (vid === null) return;
embed.value = `<video width="${vid.width}" height="${vid.height}" controls="controls"><source src="${vid.source}" /></video>`;
} else {
embed.value = embed.value
.replace(/src=".*?"/g, `src="${vid?.source}"`)
.replace(/width=".*?"/g, `width="${vid?.width}"`)
.replace(/height=".*?"/g, `height="${vid?.height}"`);
}
});
watch(embed, (newEmbed) => {
if (newEmbed === '') {
mediaSelection.value = null;
} else {
const source = /src="(.*?)"/g.exec(newEmbed)?.[1] || undefined;
const width = Number(/width="(.*?)"/g.exec(newEmbed)?.[1]) || undefined;
const height = Number(/height="(.*?)"/g.exec(newEmbed)?.[1]) || undefined;
if (source === undefined) return;
mediaSelection.value = {
source,
width,
height,
};
}
});
return {
mediaDrawerOpen,
mediaSelection,
closeMediaDrawer,
openMediaTab,
onMediaSelect,
embed,
saveMedia,
startEmbed,
mediaHeight,
mediaWidth,
mediaSource,
mediaButton,
};
function closeMediaDrawer() {
embed.value = '';
startEmbed.value = '';
mediaSelection.value = null;
mediaDrawerOpen.value = false;
openMediaTab.value = ['video'];
}
function onMediaSelect(media: Record<string, any>) {
const source = addTokenToURL(getPublicURL() + 'assets/' + media.id, imageToken.value);
mediaSelection.value = {
source,
width: media.width || 300,
height: media.height || 150,
};
}
function saveMedia() {
if (embed.value === '') return;
if (startEmbed.value !== '') {
const updatedContent = editor.value.getContent().replace(startEmbed.value, embed.value);
editor.value.setContent(updatedContent);
} else {
editor.value.selection.setContent(embed.value);
}
closeMediaDrawer();
}
}

View File

@@ -0,0 +1,28 @@
import { Ref, ref } from '@vue/composition-api';
import i18n from '@/lang';
export default function useSourceCode(editor: Ref<any>) {
const codeDrawerOpen = ref(false);
const code = ref<string>();
const sourceCodeButton = {
icon: 'sourcecode',
tooltip: i18n.t('wysiwyg_options.source_code'),
onAction: (buttonApi: any) => {
codeDrawerOpen.value = true;
code.value = editor.value.getContent();
},
};
return { codeDrawerOpen, code, closeCodeDrawer, saveCode, sourceCodeButton };
function closeCodeDrawer() {
codeDrawerOpen.value = false;
}
function saveCode() {
editor.value.setContent(code.value);
closeCodeDrawer();
}
}

View File

@@ -8,22 +8,145 @@
@onFocusIn="setFocus(true)"
@onFocusOut="setFocus(false)"
/>
<v-dialog :active="_imageDialogOpen" @toggle="unsetImageUploadHandler" @esc="unsetImageUploadHandler">
<v-dialog v-model="linkDrawerOpen">
<v-card>
<v-card-title>{{ $t('upload_from_device') }}</v-card-title>
<v-card-title class="card-title">{{ $t('wysiwyg_options.link') }}</v-card-title>
<v-card-text>
<v-upload @input="onImageUpload" :multiple="false" from-library from-url />
<div class="grid">
<div class="field">
<div class="type-label">{{ $t('url') }}</div>
<v-input v-model="linkSelection.url" :placeholder="$t('url_placeholder')"></v-input>
</div>
<div class="field">
<div class="type-label">{{ $t('display_text') }}</div>
<v-input v-model="linkSelection.displayText" :placeholder="$t('display_text_placeholder')"></v-input>
</div>
<div class="field half">
<div class="type-label">{{ $t('tooltip') }}</div>
<v-input v-model="linkSelection.title" :placeholder="$t('tooltip_placeholder')"></v-input>
</div>
<div class="field half-right">
<div class="type-label">{{ $t('open_link_in') }}</div>
<v-checkbox
block
v-model="linkSelection.newTab"
:label="$t(linkSelection.newTab ? 'new_tab' : 'current_tab')"
></v-checkbox>
</div>
</div>
</v-card-text>
<v-card-actions>
<v-button @click="unsetImageUploadHandler" secondary>{{ $t('cancel') }}</v-button>
<v-button @click="closeLinkDrawer" secondary>{{ $t('cancel') }}</v-button>
<v-button :disabled="linkSelection.url === null" @click="saveLink">{{ $t('save') }}</v-button>
</v-card-actions>
</v-card>
</v-dialog>
<v-drawer v-model="codeDrawerOpen" :title="$t('wysiwyg_options.source_code')" @cancel="closeCodeDrawer" icon="code">
<div class="content">
<interface-code v-model="code" language="htmlmixed"></interface-code>
</div>
<template #actions>
<v-button @click="saveCode" icon rounded>
<v-icon name="check" />
</v-button>
</template>
</v-drawer>
<v-drawer v-model="imageDrawerOpen" :title="$t('wysiwyg_options.image')" @cancel="closeImageDrawer" icon="image">
<div class="content">
<template v-if="imageSelection">
<img class="image-preview" :src="imageSelection.imageUrl" />
<div class="grid">
<div class="field half">
<div class="type-label">{{ $t('image_url') }}</div>
<v-input v-model="imageSelection.imageUrl" />
</div>
<div class="field half-right">
<div class="type-label">{{ $t('alt_text') }}</div>
<v-input v-model="imageSelection.alt" />
</div>
<div class="field half">
<div class="type-label">{{ $t('width') }}</div>
<v-input v-model="imageSelection.width" />
</div>
<div class="field half-right">
<div class="type-label">{{ $t('height') }}</div>
<v-input v-model="imageSelection.height" />
</div>
</div>
</template>
<v-upload v-else @input="onImageSelect" :multiple="false" from-library from-url />
</div>
<template #actions>
<v-button @click="saveImage" v-tooltip.bottom="$t('save_image')" icon rounded>
<v-icon name="check" />
</v-button>
</template>
</v-drawer>
<v-drawer
v-model="mediaDrawerOpen"
:title="$t('wysiwyg_options.media')"
@cancel="closeMediaDrawer"
icon="slideshow"
>
<template #sidebar>
<v-tabs v-model="openMediaTab" vertical>
<v-tab value="video">{{ $t('media') }}</v-tab>
<v-tab value="embed">{{ $t('embed') }}</v-tab>
</v-tabs>
</template>
<div class="content">
<v-tabs-items v-model="openMediaTab">
<v-tab-item value="video">
<template v-if="mediaSelection">
<video class="media-preview" controls="controls">
<source :src="mediaSelection.source" />
</video>
<div class="grid">
<div class="field">
<div class="type-label">{{ $t('source') }}</div>
<v-input v-model="mediaSource" />
</div>
<div class="field half">
<div class="type-label">{{ $t('width') }}</div>
<v-input v-model="mediaWidth" />
</div>
<div class="field half-right">
<div class="type-label">{{ $t('height') }}</div>
<v-input v-model="mediaHeight" />
</div>
</div>
</template>
<v-upload v-else @input="onMediaSelect" :multiple="false" from-library from-url />
</v-tab-item>
<v-tab-item value="embed">
<div class="grid">
<div class="field">
<div class="type-label">{{ $t('embed') }}</div>
<v-textarea v-model="embed" />
</div>
</div>
</v-tab-item>
</v-tabs-items>
</div>
<template #actions>
<v-button @click="saveMedia" v-tooltip.bottom="$t('save_media')" icon rounded>
<v-icon name="check" />
</v-button>
</template>
</v-drawer>
</div>
</template>
<script lang="ts">
import { defineComponent, PropType, ref, computed } from '@vue/composition-api';
import { defineComponent, PropType, ref, computed, toRefs } from '@vue/composition-api';
import 'tinymce/tinymce';
import 'tinymce/themes/silver';
@@ -45,11 +168,12 @@ import 'tinymce/plugins/directionality/plugin';
import 'tinymce/icons/default';
import Editor from '@tinymce/tinymce-vue';
import getEditorStyles from './get-editor-styles';
import { getPublicURL } from '@/utils/get-root-path';
import { addTokenToURL } from '@/api';
import useImage from './useImage';
import useMedia from './useMedia';
import useLink from './useLink';
import useSourceCode from './useSourceCode';
type CustomFormat = {
title: string;
@@ -73,17 +197,17 @@ export default defineComponent({
'italic',
'underline',
'removeformat',
'link',
'customLink',
'bullist',
'numlist',
'blockquote',
'h1',
'h2',
'h3',
'image',
'media',
'customImage',
'customMedia',
'hr',
'code',
'customCode',
'fullscreen',
],
},
@@ -109,10 +233,31 @@ export default defineComponent({
},
},
setup(props, { emit }) {
const editorRef = ref<any | null>(null);
const editorElement = ref<Vue | null>(null);
const imageUploadHandler = ref<CallableFunction | null>(null);
const { imageToken } = toRefs(props);
const _imageDialogOpen = computed(() => !!imageUploadHandler.value);
const { imageDrawerOpen, imageSelection, closeImageDrawer, onImageSelect, saveImage, imageButton } = useImage(
editorRef,
imageToken
);
const {
mediaDrawerOpen,
mediaSelection,
closeMediaDrawer,
openMediaTab,
onMediaSelect,
embed,
saveMedia,
mediaHeight,
mediaWidth,
mediaSource,
mediaButton,
} = useMedia(editorRef, imageToken);
const { linkButton, linkDrawerOpen, closeLinkDrawer, saveLink, linkSelection } = useLink(editorRef);
const { codeDrawerOpen, code, closeCodeDrawer, saveCode, sourceCodeButton } = useSourceCode(editorRef);
const _value = computed({
get() {
@@ -152,11 +297,10 @@ export default defineComponent({
extended_valid_elements: 'audio[loop],source',
toolbar: toolbarString,
style_formats: styleFormats,
file_picker_types: 'image media',
file_picker_callback: setImageUploadHandler,
urlconverter_callback: urlConverter,
file_picker_types: 'customImage customMedia image media',
link_default_protocol: 'https',
...(props.tinymceOverrides || {}),
setup,
};
});
@@ -165,43 +309,40 @@ export default defineComponent({
editorOptions,
_value,
setFocus,
onImageUpload,
unsetImageUploadHandler,
_imageDialogOpen,
onImageSelect,
saveImage,
imageDrawerOpen,
closeImageDrawer,
imageSelection,
mediaDrawerOpen,
mediaSelection,
closeMediaDrawer,
openMediaTab,
embed,
onMediaSelect,
saveMedia,
mediaHeight,
mediaWidth,
mediaSource,
linkButton,
linkDrawerOpen,
closeLinkDrawer,
saveLink,
linkSelection,
codeDrawerOpen,
code,
closeCodeDrawer,
saveCode,
sourceCodeButton,
};
function onImageUpload(file: Record<string, any>) {
if (imageUploadHandler.value) imageUploadHandler.value(file);
unsetImageUploadHandler();
}
function setup(editor: any) {
editorRef.value = editor;
function setImageUploadHandler(cb: CallableFunction, value: any, meta: Record<string, any>) {
imageUploadHandler.value = (result: Record<string, any>) => {
if (meta.filetype === 'image' && !/^image\//.test(result.type)) return;
const imageUrl = getPublicURL() + 'assets/' + result.id;
cb(imageUrl, {
alt: result.title,
title: result.title,
width: (result.width || '').toString(),
height: (result.height || '').toString(),
});
};
}
function urlConverter(url: string, node: string) {
if (url && props.imageToken && ['img', 'source', 'poster', 'audio'].includes(node)) {
const baseUrl = getPublicURL() + 'assets/';
if (url.includes(baseUrl)) {
url = addTokenToURL(url, props.imageToken);
}
}
return url;
}
function unsetImageUploadHandler() {
imageUploadHandler.value = null;
editor.ui.registry.addToggleButton('customImage', imageButton);
editor.ui.registry.addToggleButton('customMedia', mediaButton);
editor.ui.registry.addToggleButton('customLink', linkButton);
editor.ui.registry.addButton('customCode', sourceCodeButton);
}
function setFocus(val: boolean) {
@@ -226,4 +367,29 @@ export default defineComponent({
@import '~tinymce/skins/ui/oxide/skin.css';
@import './tinymce-overrides.css';
@import '@/styles/mixins/form-grid';
.grid {
@include form-grid;
}
.image-preview,
.media-preview {
width: 100%;
height: var(--input-height-tall);
margin-bottom: 24px;
border-radius: var(--border-radius);
object-fit: cover;
}
.content {
padding: var(--content-padding);
padding-top: 0;
padding-bottom: var(--content-padding);
}
::v-deep .v-card-title {
margin-bottom: 24px;
font-size: 24px;
}
</style>

View File

@@ -524,6 +524,20 @@ toggle: Toggle
icon_on: Icon On
icon_off: Icon Off
label: Label
image_url: Image Url
alt_text: Alternative Text
media: Media
width: Width
height: Height
source: Source
url_placeholder: Enter an url...
display_text: Display Text
display_text_placeholder: Enter a display text...
tooltip: Tooltip
tooltip_placeholder: Enter a tooltip...
open_link_in: Open link in
new_tab: New tab
current_tab: Current tab
wysiwyg_options:
aligncenter: Align Center
alignjustify: Align Justify
@@ -543,10 +557,10 @@ wysiwyg_options:
bullist: Bullet List
numlist: Numbered List
hr: Horizontal Rule
link: Link
link: Add/Edit Link
unlink: Remove Link
media: Add Media
image: Add Image
media: Add/Edit Media
image: Add/Edit Image
copy: Copy
cut: Cut
paste: Paste
@@ -568,7 +582,7 @@ wysiwyg_options:
selectall: Select All
table: Table
visualaid: View invisible elements
code: View Source
source_code: Edit Source Code
fullscreen: Full Screen
directionality: Directionality
dropdown: Dropdown