RichTextRenderer.vue 6.84 KB
<!--
 * @Date: 2026-02-27
 * @Description: 富文本渲染组件 - 基于 Taro v-html
 *
 * @features
 *   - HTML 实体自动解码
 *   - <a> 标签自动替换为 <div data-href="">
 *   - 图片长按预览
 *   - 文件链接点击处理
 *   - 可选的 transformElement 图片自动处理
 *
 * @example
 *   <RichTextRenderer
 *     :content="htmlContent"
 *     :enable-transform="true"
 *     @image-preview="handlePreview"
 *     @file-click="handleFileClick"
 *   />
-->
<template>
  <view id="rich-text-renderer" v-html="processedContent" class="rich-text-content"></view>
</template>

<script setup>
import { ref, watch, nextTick } from 'vue'
import Taro from '@tarojs/taro'
import { $ } from '@tarojs/extend'
import { useFileOperation } from '@/composables/useFileOperation'
import '@tarojs/taro/html.css'

/**
 * 组件属性
 */
const props = defineProps({
  /**
   * HTML 内容字符串
   */
  content: {
    type: String,
    default: ''
  },
  /**
   * 是否启用 transformElement 自动处理图片
   * @description 开启后会为非链接内的图片添加 mode="widthFix" 和 style="width: 100%"
   * @default true
   */
  enableTransform: {
    type: Boolean,
    default: true
  }
})

/**
 * 组件事件
 */
const emit = defineEmits(['image-preview', 'file-click'])

// 文件操作
const { viewFile } = useFileOperation()

// 处理后的内容
const processedContent = ref('')

// 容器 ID(用于 jQuery 选择器)
const CONTAINER_ID = '#rich-text-renderer'

/**
 * HTML 实体解码
 */
const decodeHtmlEntities = (html) => {
  return html
    .replace(/&nbsp;/g, ' ')         // 空格
    .replace(/&amp;/g, '&')          // &
    .replace(/&lt;/g, '<')           // <
    .replace(/&gt;/g, '>')           // >
    .replace(/&quot;/g, '"')         // "
    .replace(/&apos;/g, "'")         // '
}

/**
 * 替换 <a> 标签为 <div data-href="">
 */
const replaceAnchorTags = (html) => {
  let content = html
  // 替换 <a ... href="..."> 为 <div ... data-href="..." data-is-link="true">
  content = content.replace(/<a\s+/g, '<div data-is-link="true" ')
  content = content.replace(/href=/g, 'data-href=')
  content = content.replace(/<\/a>/g, '</div>')
  return content
}

/**
 * 处理 HTML 内容
 */
const processContent = (raw) => {
  if (!raw) {
    processedContent.value = ''
    return
  }

  let processed = raw

  // 1. HTML 实体解码
  processed = decodeHtmlEntities(processed)

  // 2. 替换 <a> 标签
  processed = replaceAnchorTags(processed)

  processedContent.value = processed
}

/**
 * 配置 transformElement
 */
const setupTransformElement = () => {
  if (props.enableTransform) {
    Taro.options.html.transformElement = (element) => {
      const el = element
      // 获取标签名(兼容大小写)
      const nodeName = el.nodeName?.toLowerCase() || ''
      const tagName = el.tagName?.toLowerCase() || ''

      // 只处理 img/image 标签
      const isImg = nodeName === 'img' || tagName === 'img' || nodeName === 'image' || tagName === 'image'
      if (!isImg) {
        return el
      }

      // 检查是否在链接内(遍历整个父元素链)
      let parent = el.parentElement
      let isInsideLink = false

      while (parent) {
        const pName = parent.nodeName?.toLowerCase() || ''
        const pDataIsLink = parent.getAttribute?.('data-is-link')
        const pClassList = parent.classList?.value || ''

        // 检查是否在 <a> 或带有 data-is-link 或 _file_list class 的元素内
        if (pName === 'a' || pDataIsLink === 'true' || pClassList.includes('_file_list')) {
          isInsideLink = true
          break
        }

        parent = parent.parentElement
      }

      if (isInsideLink) {
        // 在链接内的 img(图标),不处理但必须返回元素
        return el
      }

      // 在链接外的 img(内容图片),添加完整的样式
      if (el.setAttribute) {
        el.setAttribute('mode', 'widthFix')
        // 设置完整样式,确保图片宽度100%并保持正确显示
        el.setAttribute('style', 'width:100%!important;max-width:100%!important;height:auto!important;display:block;margin:24rpx 0;')
      }

      return el
    }
  } else {
    Taro.options.html.transformElement = undefined
  }
}

/**
 * 绑定图片长按预览事件
 */
const bindImageEvents = () => {
  nextTick(() => {
    const imgs = $(CONTAINER_ID).children('.h5-p').children('.h5-img')

    imgs.forEach(function (img) {
      $(img).off('longpress') // 先解绑,避免重复
      $(img).on('longpress', function () {
        const src = $(img).attr('src')

        Taro.previewImage({
          urls: [src],
          current: src,
          indicator: 'default',
          loop: false,
          success: () => {
            emit('image-preview', { src })
          },
          fail: () => {
            // 预览失败
          }
        })
      })
    })
  })
}

/**
 * 绑定文件链接点击事件
 */
const bindFileLinkEvents = () => {
  nextTick(() => {
    const container = $(CONTAINER_ID)
    const fileLinks = container.find('div[data-is-link="true"]')

    // 如果找不到 data-is-link,尝试用 class 查找
    const targetLinks = fileLinks.length > 0 ? fileLinks : container.find('._file_list')

    targetLinks.each(function (idx, el) {
      const $el = $(el)
      const dataHref = $el.attr('data-href')

      if (dataHref) {
        $el.off('tap') // 先解绑,避免重复
        $el.on('tap', function () {
          // 尝试提取文件名
          const fileName = $el.find('span span span').first().text() ||
                          $el.text().trim().substring(0, 50) ||
                          'document.pdf'

          emit('file-click', { url: dataHref, fileName })
          viewFile({ downloadUrl: dataHref, fileName })
        })
      }
    })
  })
}

/**
 * 处理内容变化
 */
const handleContentChange = () => {
  processContent(props.content)

  // 等待 DOM 更新后绑定事件
  nextTick(() => {
    bindImageEvents()
    bindFileLinkEvents()
  })
}

/**
 * 监听 props 变化
 */

// 重要:先设置 transformElement,再监听内容变化
setupTransformElement()

watch(() => props.content, handleContentChange, { immediate: true })
watch(() => props.enableTransform, () => {
  setupTransformElement()
  // 重新渲染当前内容
  if (processedContent.value) {
    const current = processedContent.value
    processedContent.value = ''
    nextTick(() => {
      processedContent.value = current
      handleContentChange()
    })
  }
})

/**
 * 组件挂载
 */
// transformElement 已在初始化时设置,watch immediate 已处理首次渲染
</script>

<style lang="less" scoped>
.rich-text-content {
  // 继承 Taro HTML 样式
  :deep(img) {
    max-width: 100%;
    height: auto;
  }

  // 文件链接样式
  :deep(div[data-is-link="true"]) {
    cursor: pointer;
    transition: opacity 0.2s;

    &:active {
      opacity: 0.7;
    }
  }
}
</style>