diff --git a/app/javascript/dashboard/components/widgets/WootWriter/FullEditor.vue b/app/javascript/dashboard/components/widgets/WootWriter/FullEditor.vue index 1f3c03a2e..4dc1ee490 100644 --- a/app/javascript/dashboard/components/widgets/WootWriter/FullEditor.vue +++ b/app/javascript/dashboard/components/widgets/WootWriter/FullEditor.vue @@ -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" /> + ({ 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: '', +}; + +const ButtonStub = { + name: 'Button', + props: ['label', 'disabled'], + emits: ['click'], + template: + '{{ label }}', +}; + +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(); + }); +}); diff --git a/app/javascript/dashboard/components/widgets/WootWriter/VideoEmbedInput.vue b/app/javascript/dashboard/components/widgets/WootWriter/VideoEmbedInput.vue new file mode 100644 index 000000000..cff806403 --- /dev/null +++ b/app/javascript/dashboard/components/widgets/WootWriter/VideoEmbedInput.vue @@ -0,0 +1,104 @@ + + + + + + + + + + + {{ showError ? t('VIDEO_EMBED.ERROR') : t('VIDEO_EMBED.HINT') }} + + + + + + + diff --git a/app/javascript/dashboard/constants/editor.js b/app/javascript/dashboard/constants/editor.js index 8af56c5e2..5dff4a3e6 100644 --- a/app/javascript/dashboard/constants/editor.js +++ b/app/javascript/dashboard/constants/editor.js @@ -186,6 +186,7 @@ export const ARTICLE_EDITOR_MENU_OPTIONS = [ 'imageUpload', 'code', 'insertTable', + 'video', ]; // [text](url) -> "text: url" (drop label if it equals the URL). Keep serializer diff --git a/app/javascript/dashboard/i18n/locale/en/components.json b/app/javascript/dashboard/i18n/locale/en/components.json index 5e84ac024..ad227b939 100644 --- a/app/javascript/dashboard/i18n/locale/en/components.json +++ b/app/javascript/dashboard/i18n/locale/en/components.json @@ -64,6 +64,14 @@ "BULLET_LIST": "Bullet List", "ORDERED_LIST": "Ordered List", "TABLE": "Table", - "IMAGE": "Image" + "IMAGE": "Image", + "VIDEO": "Video" + }, + "VIDEO_EMBED": { + "PLACEHOLDER": "Paste a video link", + "HINT": "Works with YouTube, Vimeo, Loom, Wistia, Arcade, Bunny, CodePen, GuideJar and mp4 links", + "ERROR": "That doesn't look like a supported video link", + "ADD": "Embed", + "CANCEL": "Cancel" } } diff --git a/package.json b/package.json index 48754dc28..54f1b0350 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "@amplitude/analytics-browser": "^2.11.10", "@breezystack/lamejs": "^1.2.7", "@chatwoot/ninja-keys": "1.2.3", - "@chatwoot/prosemirror-schema": "1.4.0", + "@chatwoot/prosemirror-schema": "1.4.1", "@chatwoot/utils": "^0.0.56", "@formkit/core": "^1.7.2", "@formkit/vue": "^1.7.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 881109776..443737da9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,8 +25,8 @@ importers: specifier: 1.2.3 version: 1.2.3 '@chatwoot/prosemirror-schema': - specifier: 1.4.0 - version: 1.4.0 + specifier: 1.4.1 + version: 1.4.1 '@chatwoot/utils': specifier: ^0.0.56 version: 0.0.56 @@ -452,8 +452,8 @@ packages: '@chatwoot/ninja-keys@1.2.3': resolution: {integrity: sha512-xM8d9P5ikDMZm2WbaCTk/TW5HFauylrU3cJ75fq5je6ixKwyhl/0kZbVN/vbbZN4+AUX/OaSIn6IJbtCgIF67g==} - '@chatwoot/prosemirror-schema@1.4.0': - resolution: {integrity: sha512-uitBAx1UpIZzQQYwjLnxNFKzZMJc8mv0iA79voZSH7JBCTZg7i8Y2ANvVb2oO48Y9RzWXgiDqXB3Xju1jY1llw==} + '@chatwoot/prosemirror-schema@1.4.1': + resolution: {integrity: sha512-skBLslveAW4yYQP0RkJFOJ9lXZbPWE95pLHA+AolsPKw/a1iS/g0SMXHx2gbyHoacrVX6v44+PHMKQYP6L3u0Q==} '@chatwoot/utils@0.0.56': resolution: {integrity: sha512-A6dmPLfTSrW4qYNY73btyi4PqpfzcXRSaucscZTQdzNqF6G/QUdgnBmHtho8HeiYby/kSHXaSxLJj+0dx3yEQQ==} @@ -5131,7 +5131,7 @@ snapshots: hotkeys-js: 3.8.7 lit: 2.2.6 - '@chatwoot/prosemirror-schema@1.4.0': + '@chatwoot/prosemirror-schema@1.4.1': dependencies: linkify-it: 5.0.2 markdown-it: 14.3.0
+ {{ showError ? t('VIDEO_EMBED.ERROR') : t('VIDEO_EMBED.HINT') }} +