support vimeo video embeds alongside youtube in cms editor

add framework-agnostic vimeo tiptap node (id-only src, renders player iframe)
register vimeo in shared extension set for editor and server renderer
replace youtube prompt with generic insert-video modal (detects platform)
allowlist vimeo in validator with digits-only src check
track vimeo as a lossy node for markdown round-trip detection
This commit is contained in:
corsacca 2026-05-21 14:31:27 +01:00
parent ff56eb5865
commit 01cd6ba4f4
5 changed files with 197 additions and 5 deletions

View file

@ -198,12 +198,49 @@ async function onFileChosen(e: Event) {
}
}
function insertYoutube() {
const url = window.prompt('YouTube URL')
if (!url) return
editor.value?.commands.setYoutubeVideo({ src: url })
// Video embeds (YouTube + Vimeo) share one modal. The user pastes a URL,
// we detect the platform and dispatch to the matching insert command.
const videoModalOpen = ref(false)
const videoUrl = ref('')
const videoError = ref('')
function openVideoModal() {
videoUrl.value = ''
videoError.value = ''
videoModalOpen.value = true
}
function detectVideoPlatform(url: string): 'youtube' | 'vimeo' | null {
const u = url.toLowerCase()
if (u.includes('youtube.com') || u.includes('youtu.be') || u.includes('youtube-nocookie.com')) return 'youtube'
if (u.includes('vimeo.com')) return 'vimeo'
return null
}
function insertVideo() {
const url = videoUrl.value.trim()
const e = editor.value
if (!url || !e) return
const platform = detectVideoPlatform(url)
if (!platform) {
videoError.value = 'Enter a valid YouTube or Vimeo URL.'
return
}
const inserted = platform === 'youtube'
? e.chain().focus().setYoutubeVideo({ src: url }).run()
: e.chain().focus().setVimeoVideo({ src: url }).run()
if (!inserted) {
videoError.value = `That doesn't look like a valid ${platform === 'youtube' ? 'YouTube' : 'Vimeo'} video URL.`
return
}
videoModalOpen.value = false
}
// Clear the inline error as soon as the user edits the URL again.
watch(videoUrl, () => {
if (videoError.value) videoError.value = ''
})
function insertUupgsList() {
editor.value?.chain().focus().insertContent({ type: 'uupgsList' }).run()
}
@ -319,7 +356,7 @@ function onBodyClick(e: MouseEvent) {
</template>
</UPopover>
<UButton size="xs" variant="ghost" color="neutral" icon="i-lucide-image" aria-label="Image" :loading="uploading" @click="insertImage" />
<UButton size="xs" variant="ghost" color="neutral" icon="i-lucide-youtube" aria-label="YouTube" @click="insertYoutube" />
<UButton size="xs" variant="ghost" color="neutral" icon="i-lucide-video" aria-label="Insert video" @click="openVideoModal" />
<UDropdownMenu :items="customNodeMenuItems">
<UButton
size="xs"
@ -376,6 +413,30 @@ function onBodyClick(e: MouseEvent) {
class="hidden"
@change="onFileChosen"
>
<UModal v-model:open="videoModalOpen" title="Insert video">
<template #body>
<UFormField
label="Video URL"
description="Paste a YouTube or Vimeo link."
:error="videoError || undefined"
>
<UInput
v-model="videoUrl"
autofocus
class="w-full"
placeholder="https://www.youtube.com/watch?v=… or https://vimeo.com/…"
@keydown.enter.prevent="insertVideo"
/>
</UFormField>
</template>
<template #footer>
<div class="flex justify-end gap-2">
<UButton color="neutral" variant="ghost" @click="videoModalOpen = false">Cancel</UButton>
<UButton color="primary" :disabled="!videoUrl.trim()" @click="insertVideo">Insert</UButton>
</div>
</template>
</UModal>
</div>
</template>

View file

@ -15,6 +15,7 @@ import Typography from '@tiptap/extension-typography'
import Subscript from '@tiptap/extension-subscript'
import Superscript from '@tiptap/extension-superscript'
import Youtube from '@tiptap/extension-youtube'
import { Vimeo } from './tiptapVimeo'
import { Div } from './tiptapDiv'
import { UupgsListNode } from './tiptapUupgsList'
import { GeneralResourcesNode } from './tiptapGeneralResources'
@ -32,6 +33,7 @@ export function buildTiptapExtensions() {
Subscript,
Superscript,
Youtube,
Vimeo,
Div,
UupgsListNode,
GeneralResourcesNode,

111
app/utils/tiptapVimeo.ts Normal file
View file

@ -0,0 +1,111 @@
// Vimeo embed node — the editor counterpart to @tiptap/extension-youtube,
// so an authored Vimeo URL becomes a player iframe in both the editor and
// the server-side renderer (server/utils/renderTiptap.ts via generateHTML).
// Adapted from campaigns-sever's Vimeo extension, trimmed to the options
// marketing content needs.
//
// `src` stores only the numeric Vimeo video id; renderHTML builds the
// player.vimeo.com URL from it. Keeping the stored value digits-only lets
// server/utils/tiptapValidate.ts accept it without any URL parsing.
//
// No node view — like the Verse extension we keep this framework-agnostic
// (no @tiptap/vue-3, no DOM) so generateHTML stays Vue-free on the server.
// ProseMirror renders the node straight from renderHTML in the editor.
import { Node, mergeAttributes } from '@tiptap/core'
export interface VimeoOptions {
width: number
height: number
allowFullscreen: boolean
HTMLAttributes: Record<string, unknown>
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
vimeo: {
// Insert a Vimeo embed from any Vimeo URL. Returns false (no-op)
// when the URL has no recognizable numeric video id.
setVimeoVideo: (options: { src: string }) => ReturnType
}
}
}
// Pull the numeric video id out of the common Vimeo URL shapes:
// vimeo.com/123456789, player.vimeo.com/video/123456789, and the
// channels / groups / album paths. Returns null when none match.
export function getVimeoVideoId(url: string): string | null {
if (!url) return null
const patterns = [
/(?:www\.|player\.)?vimeo\.com\/(?:channels\/(?:\w+\/)?|groups\/[^/]*\/videos\/|album\/\d+\/video\/|video\/|)(\d+)/i,
/vimeo\.com\/(\d+)/i
]
for (const pattern of patterns) {
const match = url.match(pattern)
if (match?.[1]) return match[1]
}
return null
}
export const Vimeo = Node.create<VimeoOptions>({
name: 'vimeo',
group: 'block',
draggable: true,
addOptions() {
return {
width: 640,
height: 360,
allowFullscreen: true,
HTMLAttributes: {}
}
},
addAttributes() {
return {
src: { default: null },
width: { default: this.options.width },
height: { default: this.options.height }
}
},
parseHTML() {
return [{ tag: 'div[data-vimeo-video]' }]
},
addCommands() {
return {
setVimeoVideo:
(options: { src: string }) =>
({ commands }) => {
const videoId = getVimeoVideoId(options.src)
if (!videoId) return false
return commands.insertContent({
type: this.name,
attrs: { src: videoId }
})
}
}
},
renderHTML({ HTMLAttributes }) {
const videoId = HTMLAttributes.src
const width = HTMLAttributes.width ?? this.options.width
const height = HTMLAttributes.height ?? this.options.height
return [
'div',
{ 'data-vimeo-video': '' },
[
'iframe',
mergeAttributes(this.options.HTMLAttributes, {
src: videoId ? `https://player.vimeo.com/video/${videoId}` : '',
width,
height,
frameborder: '0',
allow: 'autoplay; fullscreen; picture-in-picture',
allowfullscreen: this.options.allowFullscreen ? 'true' : null
})
]
]
}
})

View file

@ -24,6 +24,7 @@ export const LOSSY_MARKS: ReadonlySet<string> = new Set([
// Custom block nodes with no Markdown representation.
export const LOSSY_NODES: ReadonlySet<string> = new Set([
'youtube',
'vimeo',
'div',
'verse',
'uupgsList',
@ -48,6 +49,7 @@ const REASON_KEYS = {
superscript: 'superscript',
underline: 'underline',
youtube: 'youtube_embed',
vimeo: 'vimeo_embed',
div: 'div_wrapper',
verse: 'verse_block',
uupgsList: 'uupgs_list',

View file

@ -34,6 +34,7 @@ export const NODE_NAMES: ReadonlySet<string> = new Set([
'text',
'image',
'youtube',
'vimeo',
'div',
'verse',
'uupgsList',
@ -92,6 +93,7 @@ const NODE_ATTRS: Readonly<Record<string, ReadonlyArray<string>>> = {
text: [],
image: ['src', 'alt', 'title', 'align', 'width', 'height'],
youtube: ['src', 'width', 'height', 'start'],
vimeo: ['src', 'width', 'height'],
div: ['class', 'style', ...PRESERVED_DATA_ATTRS],
verse: ['reference'],
uupgsList: [
@ -142,6 +144,13 @@ function isSafeYoutubeUrl(value: unknown): boolean {
}
}
// Vimeo embeds store only the numeric video id (see app/utils/tiptapVimeo.ts);
// the renderer builds the player.vimeo.com URL from it, so a digits-only
// check is sufficient — no URL can be smuggled through.
function isSafeVimeoSrc(value: unknown): boolean {
return typeof value === 'string' && /^\d+$/.test(value)
}
// CSS color values that pass into a `color: <value>` declaration.
// Strict regexes — digits, decimal points, %, commas, parens, whitespace
// only — so even though the value lands inside a style attribute, no
@ -320,6 +329,13 @@ function walkNode(
return 'remove'
}
}
if (type === 'vimeo') {
const src = node.attrs?.src
if (!isSafeVimeoSrc(src)) {
warnings.push({ path: `${path}.attrs.src`, reason: 'vimeo.src is not a valid Vimeo video id; node dropped' })
return 'remove'
}
}
// Recurse into content
if (Array.isArray(node.content)) {