feat: add Video option to the article editor slash menu (#15164)
This commit is contained in:
@@ -27,6 +27,7 @@ import { useAlert } from 'dashboard/composables';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
|
||||
import SlashCommandMenu from './SlashCommandMenu.vue';
|
||||
import VideoEmbedInput from './VideoEmbedInput.vue';
|
||||
|
||||
const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
|
||||
const SLASH_MENU_OFFSET = 4;
|
||||
@@ -55,7 +56,7 @@ let editorView = null;
|
||||
let state;
|
||||
|
||||
export default {
|
||||
components: { SlashCommandMenu },
|
||||
components: { SlashCommandMenu, VideoEmbedInput },
|
||||
mixins: [keyboardEventListenerMixins],
|
||||
props: {
|
||||
modelValue: { type: String, default: '' },
|
||||
@@ -89,6 +90,8 @@ export default {
|
||||
slashSearchTerm: '',
|
||||
slashRange: null,
|
||||
slashMenuPosition: null,
|
||||
showVideoInput: false,
|
||||
videoInputPosition: null,
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
@@ -178,6 +181,11 @@ export default {
|
||||
executeSlashCommand(actionKey) {
|
||||
if (!editorView) return;
|
||||
|
||||
if (actionKey === 'video') {
|
||||
this.openVideoInput();
|
||||
return;
|
||||
}
|
||||
|
||||
this.removeSlashTriggerText();
|
||||
|
||||
const { schema } = editorView.state;
|
||||
@@ -247,6 +255,34 @@ export default {
|
||||
editorView.focus();
|
||||
}
|
||||
},
|
||||
openVideoInput() {
|
||||
// Capture the caret position before removing the trigger clears it.
|
||||
this.videoInputPosition = this.slashMenuPosition;
|
||||
this.removeSlashTriggerText();
|
||||
this.showVideoInput = true;
|
||||
},
|
||||
insertVideoEmbed(url) {
|
||||
this.showVideoInput = false;
|
||||
this.videoInputPosition = null;
|
||||
if (!editorView) return;
|
||||
|
||||
const { schema } = editorView.state;
|
||||
const linkMark = schema.marks.link.create({ href: url });
|
||||
const paragraph = schema.nodes.paragraph.create(
|
||||
null,
|
||||
schema.text(url, [linkMark])
|
||||
);
|
||||
const tr = editorView.state.tr.replaceSelectionWith(paragraph);
|
||||
editorView.dispatch(tr.scrollIntoView());
|
||||
state = editorView.state;
|
||||
this.emitOnChange();
|
||||
editorView.focus();
|
||||
},
|
||||
cancelVideoInput() {
|
||||
this.showVideoInput = false;
|
||||
this.videoInputPosition = null;
|
||||
editorView?.focus();
|
||||
},
|
||||
contentFromEditor() {
|
||||
if (editorView) {
|
||||
return ArticleMarkdownSerializer.serialize(editorView.state.doc);
|
||||
@@ -469,6 +505,12 @@ export default {
|
||||
:position="slashMenuPosition"
|
||||
@select-action="executeSlashCommand"
|
||||
/>
|
||||
<VideoEmbedInput
|
||||
v-if="showVideoInput"
|
||||
:position="videoInputPosition"
|
||||
@submit="insertVideoEmbed"
|
||||
@cancel="cancelVideoInput"
|
||||
/>
|
||||
<input
|
||||
ref="imageUploadInput"
|
||||
type="file"
|
||||
|
||||
@@ -66,6 +66,12 @@ const EDITOR_ACTIONS = [
|
||||
icon: 'i-lucide-image',
|
||||
menuKey: 'imageUpload',
|
||||
},
|
||||
{
|
||||
value: 'video',
|
||||
labelKey: 'SLASH_COMMANDS.VIDEO',
|
||||
icon: 'i-lucide-video',
|
||||
menuKey: 'video',
|
||||
},
|
||||
{
|
||||
value: 'strike',
|
||||
labelKey: 'SLASH_COMMANDS.STRIKETHROUGH',
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import VideoEmbedInput from './VideoEmbedInput.vue';
|
||||
|
||||
vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: key => key }) }));
|
||||
|
||||
// One representative link per provider in the real markdown_embeds.yml config
|
||||
// (github_gist is intentionally excluded from the previewable embeds list).
|
||||
const SUPPORTED_URLS = [
|
||||
['youtube (watch)', 'https://youtube.com/watch?v=dQw4w9WgXcQ'],
|
||||
['youtube (youtu.be)', 'https://youtu.be/dQw4w9WgXcQ'],
|
||||
['loom', 'https://loom.com/share/abc123'],
|
||||
['vimeo', 'https://vimeo.com/123456789'],
|
||||
['mp4', 'https://example.com/video.mp4'],
|
||||
['arcade (tab)', 'https://app.arcade.software/share/abc123?embed_mobile=tab'],
|
||||
['arcade', 'https://app.arcade.software/share/abc123'],
|
||||
['wistia', 'https://mybrand.wistia.com/medias/abc123'],
|
||||
['bunny', 'https://iframe.mediadelivery.net/embed/12345/abc-def'],
|
||||
['codepen', 'https://codepen.io/user/pen/abcXYZ'],
|
||||
['guidejar', 'https://guidejar.com/guides/abc123'],
|
||||
];
|
||||
|
||||
const InputStub = {
|
||||
name: 'Input',
|
||||
props: ['modelValue', 'messageType'],
|
||||
emits: ['update:modelValue', 'enter', 'input'],
|
||||
template: '<input :value="modelValue" />',
|
||||
};
|
||||
|
||||
const ButtonStub = {
|
||||
name: 'Button',
|
||||
props: ['label', 'disabled'],
|
||||
emits: ['click'],
|
||||
template:
|
||||
'<button :disabled="disabled" @click="$emit(\'click\')">{{ label }}</button>',
|
||||
};
|
||||
|
||||
const mountInput = () =>
|
||||
mount(VideoEmbedInput, {
|
||||
global: { stubs: { Input: InputStub, Button: ButtonStub, Icon: true } },
|
||||
});
|
||||
|
||||
const setUrl = async (wrapper, value) => {
|
||||
wrapper.findComponent({ name: 'Input' }).vm.$emit('update:modelValue', value);
|
||||
await wrapper.vm.$nextTick();
|
||||
};
|
||||
|
||||
const addButton = wrapper =>
|
||||
wrapper.findAll('button').find(b => b.text() === 'VIDEO_EMBED.ADD');
|
||||
|
||||
describe('VideoEmbedInput', () => {
|
||||
it.each(SUPPORTED_URLS)(
|
||||
'enables Embed and submits a %s link',
|
||||
async (_label, url) => {
|
||||
const wrapper = mountInput();
|
||||
await setUrl(wrapper, url);
|
||||
|
||||
const add = addButton(wrapper);
|
||||
expect(add.attributes('disabled')).toBeUndefined();
|
||||
|
||||
await add.trigger('click');
|
||||
expect(wrapper.emitted('submit')).toEqual([[url]]);
|
||||
}
|
||||
);
|
||||
|
||||
it('disables Embed and shows the error for an unsupported link', async () => {
|
||||
const wrapper = mountInput();
|
||||
await setUrl(wrapper, 'https://example.com/not-a-video');
|
||||
|
||||
expect(addButton(wrapper).attributes('disabled')).toBeDefined();
|
||||
|
||||
wrapper.findComponent({ name: 'Input' }).vm.$emit('enter');
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(wrapper.emitted('submit')).toBeFalsy();
|
||||
expect(wrapper.text()).toContain('VIDEO_EMBED.ERROR');
|
||||
});
|
||||
|
||||
it('ignores a submit while the field is empty', async () => {
|
||||
const wrapper = mountInput();
|
||||
wrapper.findComponent({ name: 'Input' }).vm.$emit('enter');
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(wrapper.emitted('submit')).toBeFalsy();
|
||||
});
|
||||
|
||||
it('emits cancel from the Cancel button', async () => {
|
||||
const wrapper = mountInput();
|
||||
const cancel = wrapper
|
||||
.findAll('button')
|
||||
.find(b => b.text() === 'VIDEO_EMBED.CANCEL');
|
||||
await cancel.trigger('click');
|
||||
expect(wrapper.emitted('cancel')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { embeds } from 'dashboard/helper/markdownEmbeds';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
position: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['submit', 'cancel']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const url = ref('');
|
||||
const showError = ref(false);
|
||||
|
||||
const isSupported = value =>
|
||||
embeds.some(({ regex }) => regex.test(value.trim()));
|
||||
|
||||
const isValid = computed(() => isSupported(url.value));
|
||||
|
||||
const menuStyle = computed(() => {
|
||||
if (!props.position) return {};
|
||||
const style = { top: `${props.position.top}px` };
|
||||
if (props.position.right != null) {
|
||||
style.right = `${props.position.right}px`;
|
||||
} else {
|
||||
style.left = `${props.position.left}px`;
|
||||
}
|
||||
return style;
|
||||
});
|
||||
|
||||
const onSubmit = () => {
|
||||
// Ignore submits on an empty field (e.g. the stray Enter keyup that carries
|
||||
// over from selecting "Video" in the slash menu).
|
||||
if (!url.value.trim()) return;
|
||||
if (!isValid.value) {
|
||||
showError.value = true;
|
||||
return;
|
||||
}
|
||||
emit('submit', url.value.trim());
|
||||
};
|
||||
|
||||
const onInput = () => {
|
||||
showError.value = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="absolute z-50 flex flex-col p-3 shadow-lg gap-2.5 w-[22rem] bg-n-solid-2 outline outline-1 outline-n-weak rounded-xl"
|
||||
:style="menuStyle"
|
||||
>
|
||||
<Input
|
||||
v-model="url"
|
||||
type="url"
|
||||
autofocus
|
||||
custom-input-class="!ps-9"
|
||||
:placeholder="t('VIDEO_EMBED.PLACEHOLDER')"
|
||||
:message-type="showError ? 'error' : 'info'"
|
||||
@input="onInput"
|
||||
@enter="onSubmit"
|
||||
@keydown.esc.prevent="emit('cancel')"
|
||||
>
|
||||
<template #prefix>
|
||||
<Icon
|
||||
icon="i-lucide-video"
|
||||
class="absolute z-10 -translate-y-1/2 pointer-events-none start-3 top-5 size-4"
|
||||
:class="showError ? 'text-n-ruby-9' : 'text-n-slate-11'"
|
||||
/>
|
||||
</template>
|
||||
</Input>
|
||||
<p
|
||||
class="px-1 text-xs leading-snug"
|
||||
:class="showError ? 'text-n-ruby-11' : 'text-n-slate-10'"
|
||||
>
|
||||
{{ showError ? t('VIDEO_EMBED.ERROR') : t('VIDEO_EMBED.HINT') }}
|
||||
</p>
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
ghost
|
||||
slate
|
||||
sm
|
||||
type="button"
|
||||
:label="t('VIDEO_EMBED.CANCEL')"
|
||||
@click="emit('cancel')"
|
||||
/>
|
||||
<Button
|
||||
solid
|
||||
sm
|
||||
type="button"
|
||||
:label="t('VIDEO_EMBED.ADD')"
|
||||
:disabled="!isValid"
|
||||
@click="onSubmit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user