{{ t('source') }}
@@ -182,13 +186,10 @@ import 'tinymce/icons/default';
import Editor from '@tinymce/tinymce-vue';
import getEditorStyles from './get-editor-styles';
-import { escapeRegExp } from 'lodash';
import useImage from './useImage';
import useMedia from './useMedia';
import useLink from './useLink';
import useSourceCode from './useSourceCode';
-import { getToken } from '@/api';
-import { getPublicURL } from '@/utils/get-root-path';
import { percentage } from '@/utils/percentage';
type CustomFormat = {
@@ -265,13 +266,13 @@ export default defineComponent({
const { t } = useI18n();
const editorRef = ref
(null);
const editorElement = ref(null);
- const isEditorDirty = ref(false);
const { imageToken } = toRefs(props);
let tinymceEditor: HTMLElement | null;
let count = ref(0);
onMounted(() => {
let iframe;
+ let contentLoaded = false;
const wysiwyg = document.getElementById(props.field);
if (wysiwyg) iframe = wysiwyg.getElementsByTagName('iframe');
@@ -282,6 +283,11 @@ export default defineComponent({
if (tinymceEditor) {
const observer = new MutationObserver((_mutations) => {
count.value = tinymceEditor?.textContent?.replace('\n', '')?.length ?? 0;
+ if (!contentLoaded) {
+ contentLoaded = true;
+ } else {
+ emit('input', editorRef.value.getContent());
+ }
});
const config = { characterData: true, childList: true, subtree: true };
@@ -291,7 +297,6 @@ export default defineComponent({
const { imageDrawerOpen, imageSelection, closeImageDrawer, onImageSelect, saveImage, imageButton } = useImage(
editorRef,
- isEditorDirty,
imageToken
);
@@ -307,51 +312,18 @@ export default defineComponent({
mediaWidth,
mediaSource,
mediaButton,
- } = useMedia(editorRef, isEditorDirty, imageToken);
+ } = useMedia(editorRef, imageToken);
- const { linkButton, linkDrawerOpen, closeLinkDrawer, saveLink, linkSelection } = useLink(editorRef, isEditorDirty);
+ const { linkButton, linkDrawerOpen, closeLinkDrawer, saveLink, linkSelection } = useLink(editorRef);
- const { codeDrawerOpen, code, closeCodeDrawer, saveCode, sourceCodeButton } = useSourceCode(
- editorRef,
- isEditorDirty
- );
-
- const replaceTokens = (value: string, token: string | null) => {
- const url = getPublicURL();
- const regex = new RegExp(
- `(<[^]+?=")(${escapeRegExp(
- url
- )}assets/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(?:\\?[^#"]*)?(?:#[^"]*)?)("[^>]*>)`,
- 'gi'
- );
-
- return value.replace(regex, (_, pre, matchedUrl, post) => {
- const matched = new URL(matchedUrl.replace(/&/g, '&'));
- const params = new URLSearchParams(matched.search);
-
- if (!token) {
- params.delete('access_token');
- } else {
- params.set('access_token', token);
- }
-
- const paramsString = params.toString().length > 0 ? `?${params.toString().replace(/&/g, '&')}` : '';
-
- return `${pre}${matched.origin}${matched.pathname}${paramsString}${post}`;
- });
- };
+ const { codeDrawerOpen, code, closeCodeDrawer, saveCode, sourceCodeButton } = useSourceCode(editorRef);
const internalValue = computed({
get() {
- if (!props.value) return '';
- return replaceTokens(props.value, getToken());
+ return props.value || '';
},
- set(newValue: string) {
- if (!isEditorDirty.value) return;
- if (newValue !== props.value && (props.value === null && newValue === '') === false) {
- const removeToken = replaceTokens(newValue, props.imageToken ?? null);
- emit('input', removeToken);
- }
+ set() {
+ return;
},
});
@@ -390,7 +362,7 @@ export default defineComponent({
menubar: false,
convert_urls: false,
image_dimensions: false,
- extended_valid_elements: 'audio[loop],source',
+ extended_valid_elements: 'audio[loop|controls],source',
toolbar: toolbarString,
style_formats: styleFormats,
file_picker_types: 'customImage customMedia image media',
@@ -410,7 +382,6 @@ export default defineComponent({
editorOptions,
internalValue,
setFocus,
- setDirty,
onImageSelect,
saveImage,
imageDrawerOpen,
@@ -447,10 +418,6 @@ export default defineComponent({
editor.ui.registry.addButton('customCode', sourceCodeButton);
}
- function setDirty() {
- isEditorDirty.value = true;
- }
-
function setFocus(val: boolean) {
if (editorElement.value == null) return;
const body = editorElement.value.$el.parentElement?.querySelector('.tox-tinymce');
diff --git a/app/src/interfaces/input-rich-text-html/useImage.ts b/app/src/interfaces/input-rich-text-html/useImage.ts
index f6bd4dd199..9be2386ec4 100644
--- a/app/src/interfaces/input-rich-text-html/useImage.ts
+++ b/app/src/interfaces/input-rich-text-html/useImage.ts
@@ -1,4 +1,4 @@
-import { addTokenToURL } from '@/api';
+import { getToken } from '@/api';
import { i18n } from '@/lang';
import { addQueryToPath } from '@/utils/add-query-to-path';
import { getPublicURL } from '@/utils/get-root-path';
@@ -28,11 +28,7 @@ type UsableImage = {
imageButton: ImageButton;
};
-export default function useImage(
- editor: Ref,
- isEditorDirty: Ref,
- imageToken: Ref
-): UsableImage {
+export default function useImage(editor: Ref, imageToken: Ref): UsableImage {
const imageDrawerOpen = ref(false);
const imageSelection = ref(null);
@@ -59,7 +55,7 @@ export default function useImage(
alt,
width,
height,
- previewUrl: imageUrl,
+ previewUrl: replaceUrlAccessToken(imageUrl, imageToken.value ?? getToken()),
};
} else {
imageSelection.value = null;
@@ -86,14 +82,14 @@ export default function useImage(
}
function onImageSelect(image: Record) {
- const imageUrl = addTokenToURL(getPublicURL() + 'assets/' + image.id, imageToken.value);
+ const assetUrl = getPublicURL() + 'assets/' + image.id;
imageSelection.value = {
- imageUrl,
+ imageUrl: replaceUrlAccessToken(assetUrl, imageToken.value),
alt: image.title,
width: image.width,
height: image.height,
- previewUrl: imageUrl,
+ previewUrl: replaceUrlAccessToken(assetUrl, imageToken.value ?? getToken()),
};
}
@@ -105,8 +101,30 @@ export default function useImage(
...(img.height ? { height: img.height.toString() } : {}),
});
const imageHtml = `
`;
- isEditorDirty.value = true;
editor.value.selection.setContent(imageHtml);
closeImageDrawer();
}
+
+ function replaceUrlAccessToken(url: string, token: string | null | undefined): string {
+ // Only process assets URL
+ if (!url.includes(getPublicURL() + 'assets/')) {
+ return url;
+ }
+ try {
+ const parsedUrl = new URL(url);
+ const params = new URLSearchParams(parsedUrl.search);
+
+ if (!token) {
+ params.delete('access_token');
+ } else {
+ params.set('access_token', token);
+ }
+
+ return Array.from(params).length > 0
+ ? `${parsedUrl.origin}${parsedUrl.pathname}?${params.toString()}`
+ : `${parsedUrl.origin}${parsedUrl.pathname}`;
+ } catch {
+ return url;
+ }
+ }
}
diff --git a/app/src/interfaces/input-rich-text-html/useLink.ts b/app/src/interfaces/input-rich-text-html/useLink.ts
index c152a80719..596e28caea 100644
--- a/app/src/interfaces/input-rich-text-html/useLink.ts
+++ b/app/src/interfaces/input-rich-text-html/useLink.ts
@@ -23,7 +23,7 @@ type UsableLink = {
linkButton: LinkButton;
};
-export default function useLink(editor: Ref, isEditorDirty: Ref): UsableLink {
+export default function useLink(editor: Ref): UsableLink {
const linkDrawerOpen = ref(false);
const defaultLinkSelection = {
url: null,
@@ -94,7 +94,6 @@ export default function useLink(editor: Ref, isEditorDirty: Ref):
link.displayText || link.url
}`;
- isEditorDirty.value = true;
editor.value.selection.setContent(linkHtml);
closeLinkDrawer();
}
diff --git a/app/src/interfaces/input-rich-text-html/useMedia.ts b/app/src/interfaces/input-rich-text-html/useMedia.ts
index 3827cea0e0..4db05d9b01 100644
--- a/app/src/interfaces/input-rich-text-html/useMedia.ts
+++ b/app/src/interfaces/input-rich-text-html/useMedia.ts
@@ -1,14 +1,15 @@
-import { addTokenToURL } from '@/api';
+import { getToken } from '@/api';
import { i18n } from '@/lang';
import { getPublicURL } from '@/utils/get-root-path';
import { computed, Ref, ref, watch } from 'vue';
type MediaSelection = {
- source: string;
+ sourceUrl: string;
width?: number;
height?: number;
- tag?: 'video' | 'audio';
+ tag?: 'video' | 'audio' | 'iframe';
type?: string;
+ previewUrl?: string;
};
type MediaButton = {
@@ -33,11 +34,7 @@ type UsableMedia = {
mediaButton: MediaButton;
};
-export default function useMedia(
- editor: Ref,
- isEditorDirty: Ref,
- imageToken: Ref
-): UsableMedia {
+export default function useMedia(editor: Ref, imageToken: Ref): UsableMedia {
const mediaDrawerOpen = ref(false);
const mediaSelection = ref(null);
const openMediaTab = ref(['video', 'audio']);
@@ -76,10 +73,14 @@ export default function useMedia(
const mediaSource = computed({
get() {
- return mediaSelection.value?.source;
+ return mediaSelection.value?.sourceUrl;
},
set(newSource: any) {
- mediaSelection.value = { ...mediaSelection.value, source: newSource };
+ mediaSelection.value = {
+ ...mediaSelection.value,
+ sourceUrl: newSource,
+ };
+ mediaSelection.value.previewUrl = replaceUrlAccessToken(newSource, imageToken.value || getToken());
},
});
@@ -106,15 +107,15 @@ export default function useMedia(
watch(mediaSelection, (vid) => {
if (embed.value === '') {
if (vid === null) return;
- embed.value = `<${vid.tag} width="${vid.width}" height="${vid.height}" controls>${vid.tag}>`;
+ embed.value = `<${vid.tag} width="${vid.width}" height="${vid.height}" controls>${vid.tag}>`;
} else {
embed.value = embed.value
- .replace(/src=".*?"/g, `src="${vid?.source}"`)
+ .replace(/src=".*?"/g, `src="${vid?.sourceUrl}"`)
.replace(/width=".*?"/g, `width="${vid?.width}"`)
.replace(/height=".*?"/g, `height="${vid?.height}"`)
.replace(/type=".*?"/g, `type="${vid?.type}"`)
- .replaceAll(/<(video|audio)/g, `<${vid?.tag}`)
- .replaceAll(/<\/(video|audio)/g, `${vid?.tag}`);
+ .replaceAll(/<(video|audio|iframe)/g, `<${vid?.tag}`)
+ .replaceAll(/<\/(video|audio|iframe)/g, `${vid?.tag}`);
}
});
@@ -122,20 +123,24 @@ export default function useMedia(
if (newEmbed === '') {
mediaSelection.value = null;
} else {
- const tag = /<(video|audio)/g.exec(newEmbed)?.[1];
- const source = /src="(.*?)"/g.exec(newEmbed)?.[1] || undefined;
+ const tag = /<(video|audio|iframe)/g.exec(newEmbed)?.[1];
+ const sourceUrl = /src="(.*?)"/g.exec(newEmbed)?.[1] || undefined;
const width = Number(/width="(.*?)"/g.exec(newEmbed)?.[1]) || undefined;
const height = Number(/height="(.*?)"/g.exec(newEmbed)?.[1]) || undefined;
const type = /type="(.*?)"/g.exec(newEmbed)?.[1] || undefined;
- if (source === undefined) return;
+ if (sourceUrl === undefined) return;
+
+ // Add temporarily access token for preview
+ const previewUrl = replaceUrlAccessToken(sourceUrl, imageToken.value || getToken());
mediaSelection.value = {
- tag: tag === 'audio' ? 'audio' : 'video',
- source,
+ tag: tag === 'audio' ? 'audio' : tag === 'iframe' ? 'iframe' : 'video',
+ sourceUrl,
width,
height,
type,
+ previewUrl,
};
}
});
@@ -164,23 +169,22 @@ export default function useMedia(
}
function onMediaSelect(media: Record) {
- const url = getPublicURL() + 'assets/' + media.id;
+ const sourceUrl = getPublicURL() + 'assets/' + media.id;
const tag = media.type.startsWith('audio') ? 'audio' : 'video';
- const source = imageToken.value ? addTokenToURL(url, imageToken.value) : url;
mediaSelection.value = {
- source,
+ sourceUrl: replaceUrlAccessToken(sourceUrl, imageToken.value),
width: media.width || 300,
height: media.height || 150,
tag,
type: media.type,
+ previewUrl: replaceUrlAccessToken(sourceUrl, imageToken.value || getToken()),
};
}
function saveMedia() {
if (embed.value === '') return;
- isEditorDirty.value = true;
if (startEmbed.value !== '') {
const updatedContent = editor.value.getContent().replace(startEmbed.value, embed.value);
editor.value.setContent(updatedContent);
@@ -189,4 +193,27 @@ export default function useMedia(
}
closeMediaDrawer();
}
+
+ function replaceUrlAccessToken(url: string, token: string | null | undefined): string {
+ // Only process assets URL
+ if (!url.includes(getPublicURL() + 'assets/')) {
+ return url;
+ }
+ try {
+ const parsedUrl = new URL(url);
+ const params = new URLSearchParams(parsedUrl.search);
+
+ if (!token) {
+ params.delete('access_token');
+ } else {
+ params.set('access_token', token);
+ }
+
+ return Array.from(params).length > 0
+ ? `${parsedUrl.origin}${parsedUrl.pathname}?${params.toString()}`
+ : `${parsedUrl.origin}${parsedUrl.pathname}`;
+ } catch {
+ return url;
+ }
+ }
}
diff --git a/app/src/interfaces/input-rich-text-html/useSourceCode.ts b/app/src/interfaces/input-rich-text-html/useSourceCode.ts
index dd0273a944..b65f4cf7e6 100644
--- a/app/src/interfaces/input-rich-text-html/useSourceCode.ts
+++ b/app/src/interfaces/input-rich-text-html/useSourceCode.ts
@@ -15,7 +15,7 @@ type UsableSourceCode = {
sourceCodeButton: SourceCodeButton;
};
-export default function useSourceCode(editor: Ref, isEditorDirty: Ref): UsableSourceCode {
+export default function useSourceCode(editor: Ref): UsableSourceCode {
const codeDrawerOpen = ref(false);
const code = ref();
@@ -35,7 +35,6 @@ export default function useSourceCode(editor: Ref, isEditorDirty: Ref {
- let mdString = props.value || '';
-
- if (!props.imageToken) {
- const baseUrl = getPublicURL() + 'assets/';
- const regex = new RegExp(`\\]\\((${escapeStringRegexp(baseUrl)}[^\\s\\)]*)`, 'gm');
-
- const images = Array.from(mdString.matchAll(regex));
-
- for (const image of images) {
- mdString = mdString.replace(image[1], addTokenToURL(image[1]));
- }
- }
-
- return mdString;
+ return props.value || '';
});
const table = reactive({
@@ -400,7 +385,7 @@ export default defineComponent({
url += '?access_token=' + props.imageToken;
}
- codemirror.replaceSelection(``);
+ codemirror.replaceSelection(``);
imageDialogOpen.value = false;
}
diff --git a/app/src/lang/translations/en-US.yaml b/app/src/lang/translations/en-US.yaml
index 03fdd7bb45..7a2dc5fd87 100644
--- a/app/src/lang/translations/en-US.yaml
+++ b/app/src/lang/translations/en-US.yaml
@@ -742,6 +742,8 @@ unlimited: Unlimited
open_link_in: Open link in
new_tab: New tab
current_tab: Current tab
+save_image: Save Image
+save_media: Save Media
wysiwyg_options:
aligncenter: Align Center
alignjustify: Align Justify
@@ -1419,8 +1421,8 @@ interfaces:
customSyntax_label: Add custom syntax types
customSyntax_add: Add custom syntax
box: Block / Inline
- imageToken: Image Token
- imageToken_label: What (static) token to append to image sources
+ imageToken: Static Access Token
+ imageToken_label: Static access token is appended to the assets' URL
map:
map: Map
description: Select a location on a map
@@ -1527,8 +1529,8 @@ interfaces:
custom_formats: Custom Formats
options_override: Options Override
folder_note: Folder for uploaded files. Does not affect existing files.
- imageToken: Image Token
- imageToken_label: What (static) token to append to image sources
+ imageToken: Static Access Token
+ imageToken_label: Static access token is appended to the assets' URL
input-autocomplete-api:
input-autocomplete-api: Autocomplete Input (API)
description: A search typeahead for external API values.