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:
parent
ff56eb5865
commit
01cd6ba4f4
5 changed files with 197 additions and 5 deletions
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
111
app/utils/tiptapVimeo.ts
Normal 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
|
||||
})
|
||||
]
|
||||
]
|
||||
}
|
||||
})
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue