feat: add Video option to the article editor slash menu (#15164)

This commit is contained in:
Sivin Varghese
2026-07-29 17:25:58 +05:30
committed by GitHub
parent bc9839ed38
commit 754b25cd59
8 changed files with 263 additions and 8 deletions

View File

@@ -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"

View 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',

View File

@@ -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();
});
});

View File

@@ -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>