md-editor-v3(版本:5.8.5)插件自定义工具栏增加嵌入YouTube视频功能

先看效果

第一步:先引入 utils/youtube.ts 文件

ts 复制代码
// utils/youtube.ts

/**
 * YouTube 链接解析结果
 */
export interface YouTubeParseResult {
  videoId: string | null
  type: 'watch' | 'youtu.be' | 'embed' | 'short' | 'invalid'
  timestamp?: number // 时间戳(秒)
  playlistId?: string | null
}

/**
 * 从 YouTube 链接中提取视频 ID
 * 支持格式:
 * - https://www.youtube.com/watch?v=VIDEO_ID
 * - https://youtu.be/VIDEO_ID
 * - https://www.youtube.com/embed/VIDEO_ID
 * - https://www.youtube.com/shorts/VIDEO_ID
 * - https://www.youtube.com/watch?v=VIDEO_ID&t=30s
 *
 * @param url YouTube 链接
 * @returns 视频 ID,如果无法解析则返回 null
 */
export function getYouTubeVideoId(url: string): string | null {
  if (!url || typeof url !== 'string') return null

  try {
    const parsedUrl = new URL(url)
    const hostname = parsedUrl.hostname

    // 1. 标准 watch 链接: ?v=VIDEO_ID
    if (hostname.includes('youtube.com') || hostname.includes('youtu.be')) {
      // 尝试从查询参数获取 v
      const videoId = parsedUrl.searchParams.get('v')
      if (videoId) return videoId

      // 2. youtu.be/VIDEO_ID 或 youtube.com/embed/VIDEO_ID
      const pathname = parsedUrl.pathname
      const pathSegments = pathname.split('/').filter((seg) => seg.length > 0)

      // /embed/VIDEO_ID
      if (pathname.includes('/embed/') && pathSegments.length >= 2) {
        return pathSegments[pathSegments.length - 1]
      }

      // /shorts/VIDEO_ID
      if (pathname.includes('/shorts/') && pathSegments.length >= 2) {
        return pathSegments[pathSegments.length - 1]
      }

      // youtu.be/VIDEO_ID
      if (hostname.includes('youtu.be') && pathSegments.length >= 1) {
        return pathSegments[0]
      }

      // /v/VIDEO_ID (旧格式)
      if (pathname.includes('/v/') && pathSegments.length >= 2) {
        return pathSegments[pathSegments.length - 1]
      }
    }

    return null
  } catch {
    // URL 格式无效
    return null
  }
}

/**
 * 从 YouTube 链接中提取完整信息(视频 ID、类型、时间戳等)
 */
export function parseYouTubeUrl(url: string): YouTubeParseResult {
  const defaultResult: YouTubeParseResult = {
    videoId: null,
    type: 'invalid',
  }

  if (!url || typeof url !== 'string') return defaultResult

  try {
    const parsedUrl = new URL(url)
    const hostname = parsedUrl.hostname
    let videoId: string | null = null
    let type: YouTubeParseResult['type'] = 'invalid'
    let timestamp: number | undefined
    const playlistId = parsedUrl.searchParams.get('list')

    // 解析时间戳
    const tParam = parsedUrl.searchParams.get('t')
    if (tParam) {
      const match = tParam.match(/^(\d+)(?:s|m|h)?$/)
      if (match) {
        timestamp = parseInt(match[1], 10)
      }
    }

    // 检测链接类型并提取 ID
    if (hostname.includes('youtube.com') || hostname.includes('youtu.be')) {
      // 标准 watch 链接
      const vParam = parsedUrl.searchParams.get('v')
      if (vParam) {
        videoId = vParam
        type = 'watch'
        return { videoId, type, timestamp, playlistId }
      }

      const pathname = parsedUrl.pathname
      const pathSegments = pathname.split('/').filter((seg) => seg.length > 0)

      // youtu.be/VIDEO_ID
      if (hostname.includes('youtu.be') && pathSegments.length >= 1) {
        videoId = pathSegments[0]
        type = 'youtu.be'
        return { videoId, type, timestamp, playlistId }
      }

      // /embed/VIDEO_ID
      if (pathname.includes('/embed/') && pathSegments.length >= 2) {
        videoId = pathSegments[pathSegments.length - 1]
        type = 'embed'
        return { videoId, type, timestamp, playlistId }
      }

      // /shorts/VIDEO_ID
      if (pathname.includes('/shorts/') && pathSegments.length >= 2) {
        videoId = pathSegments[pathSegments.length - 1]
        type = 'short'
        return { videoId, type, timestamp, playlistId }
      }
    }

    return { videoId: null, type: 'invalid' }
  } catch {
    return defaultResult
  }
}

/**
 * 验证 YouTube 链接是否有效
 */
export function isValidYouTubeUrl(url: string): boolean {
  return getYouTubeVideoId(url) !== null
}

/**
 * 生成 YouTube 嵌入链接
 * @param videoId 视频 ID
 * @param options 配置选项
 */
export function getYouTubeEmbedUrl(
  videoId: string,
  options?: {
    autoplay?: boolean
    muted?: boolean
    start?: number // 开始时间(秒)
    end?: number // 结束时间(秒)
    controls?: boolean
    rel?: boolean // 是否显示相关视频
  },
): string {
  const base = `https://www.youtube.com/embed/${videoId}`
  const params = new URLSearchParams()

  if (options?.autoplay) params.set('autoplay', '1')
  if (options?.muted) params.set('mute', '1')
  if (options?.start) params.set('start', String(options.start))
  if (options?.end) params.set('end', String(options.end))
  if (options?.controls === false) params.set('controls', '0')
  if (options?.rel === false) params.set('rel', '0')

  const queryString = params.toString()
  return queryString ? `${base}?${queryString}` : base
}

第二步:编写vue代码

vue 复制代码
<template>
  <MdEditor
    theme="dark"
    ref="mdEditorRef"
    v-model="content"
    preview-theme="github"
    :toolbars="toolbars"
  >
    <template #defToolbars>
      <div class="md-editor-toolbar-item" title="嵌入YouTube视频" @click="insertYouTubeIframe">
        <svg
          xmlns="http://www.w3.org/2000/svg"
          width="20"
          height="20"
          viewBox="0 0 24 24"
          fill="currentColor"
        >
          <path
            d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814z"
          />
          <path d="M9.545 15.568L15.818 12 9.545 8.432v7.136z" fill="#ffffff" />
        </svg>
      </div>
    </template>
  </MdEditor>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { MdEditor } from 'md-editor-v3'
import 'md-editor-v3/lib/style.css'
import { ElMessageBox } from 'element-plus'
import { getYouTubeEmbedUrl, parseYouTubeUrl } from '@/utils/youtube.ts'

const content = ref('')
const mdEditorRef = ref<any>()

// md编辑器工具栏
const toolbars: any[] = [
  'bold',
  'underline',
  'italic',
  '-',
  'title',
  'strikeThrough',
  'sub',
  'sup',
  'quote',
  'unorderedList',
  'orderedList',
  '-',
  'codeRow',
  'code',
  'link',
  'image',
  'table',
  0, // 这里的名字要和下面插槽内对应(或者直接按插槽顺序渲染)
  '-',
  'revoke',
  'next',
  'save',
  '=',
  'pageFullscreen',
  'fullscreen',
  'preview',
  'htmlPreview',
  'catalog',
]

const insertYouTubeIframe = async () => {
  const selectedText = mdEditorRef.value?.getSelectedText().trim()

  const { value } = await ElMessageBox.prompt('请填入视频地址:', '嵌入YouTube', {
    confirmButtonText: '插入',
    cancelButtonText: '取消',
    inputValue: selectedText,
    inputPattern: /^https?:\/\/.+/,
    inputErrorMessage: '请输入有效的地址',
  })

  const inputValueUrl = value.trim()
  if (!inputValueUrl) return ElMessageBox.alert('视频地址不能为空')

  const youtube = parseYouTubeUrl(inputValueUrl)
  if (youtube.type === 'invalid') return ElMessageBox.alert('invalid')

  const videoId = youtube.videoId ? youtube.videoId : ''
  const embedUrl = getYouTubeEmbedUrl(videoId, { start: youtube.timestamp })

  // 安全地通过 CodeMirror 实例注入文本,保留历史记录(撤销/重做)
  mdEditorRef.value?.insert(() => {
    const iframeHtml = `<div style="width: 100%;aspect-ratio: 16 / 9;">
  <iframe
  src="${embedUrl}"
  allowfullscreen="allowfullscreen"
  style="border: none;border-radius:8px;width: 100%;height: 100%"
  ></iframe>
</div>`

    return {
      targetValue: iframeHtml,
      select: false,
      deviationStart: 0,
      deviationEnd: 0,
    }
  })
}
</script>

下面是个例子

2011年NBA总决赛-达拉斯对阵迈阿密-第六场比赛最佳镜头

评论