vue3+lodash+ts+tailwin 实现多行文本的展开收起代码(支持渲染html)

ts 复制代码
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, nextTick, computed } from 'vue'
import { debounce } from 'lodash-es'

interface Props {
  text: string
  maxLines?: number
  expandText?: string
  collapseText?: string
  expandClass?: string
  collapseClass?: string
}

const props = withDefaults(defineProps<Props>(), {
  maxLines: 3,
  expandText: '展开',
  collapseText: '收起',
  expandClass: 'text-blue-500',
  collapseClass: 'text-blue-500',
})

const containerRef = ref<HTMLElement>()
const expanded = ref(false)
const isTruncated = ref(false)
const truncatedHtml = ref(props.text)

// ─── HTML 工具 ───────────────────────────────────────────────

/** 块级标签集合:仅这些标签会被认为产生新行,用于"在最后一行末尾追加"判断 */
const BLOCK_TAGS = new Set([
  'DIV', 'P', 'SECTION', 'ARTICLE', 'BLOCKQUOTE',
  'LI', 'UL', 'OL', 'PRE',
  'H1', 'H2', 'H3', 'H4', 'H5', 'H6',
])

/**
 * 把 suffixHtml 注入到 html 的「最深一个块级容器」内部末尾,
 * 保证它与最后一行可见文字处于同一内联流。
 * 碰到 <strong> 等行内元素会停住,避免继承粗体等样式。
 */
function appendInsideLastBlock(html: string, suffixHtml: string): string {
  const wrapper = document.createElement('div')
  wrapper.innerHTML = html
  let target: Element = wrapper
  while (target.lastElementChild && BLOCK_TAGS.has(target.lastElementChild.tagName)) {
    target = target.lastElementChild
  }
  target.insertAdjacentHTML('beforeend', suffixHtml)
  return wrapper.innerHTML
}

/** 把 HTML 字符串转成纯文本(保留换行语义) */
function htmlToPlainText(html: string): string {
  const div = document.createElement('div')
  div.innerHTML = html
  // <br> / <p> / <div> 换成换行,方便行高量测一致
  div.querySelectorAll('br').forEach(br => br.replaceWith('\n'))
  div.querySelectorAll('p, div').forEach(el => {
    el.prepend('\n')
  })
  return div.innerText ?? div.textContent ?? ''
}

/**
 * 将"纯文本截断到第 visibleLen 个字符"映射回原始 HTML,
 * 返回一段合法闭合的 HTML 片段。
 *
 * 思路:遍历原始 HTML 字符,跳过标签字符,只计可见字符数;
 * 找到第 visibleLen 个可见字符在原始字符串中的位置后截断,
 * 再用 DOMParser 补全未闭合标签。
 */
function sliceHtmlByVisibleLen(html: string, visibleLen: number): string {
  let visible = 0
  let i = 0
  let inTag = false

  while (i < html.length && visible < visibleLen) {
    const ch = html[i]
    if (ch === '<') {
      inTag = true
    } else if (ch === '>') {
      inTag = false
    } else if (!inTag) {
      visible++
    }
    i++
  }

  // i 现在指向截断位置(继续把当前标签走完,避免截断在标签内部)
  if (inTag) {
    const closeIdx = html.indexOf('>', i)
    i = closeIdx === -1 ? html.length : closeIdx + 1
  }

  const raw = html.slice(0, i)

  // 用 DOMParser 补全未闭合标签
  const doc = new DOMParser().parseFromString(raw, 'text/html')
  return doc.body.innerHTML
}

// ─── 样式量测 ────────────────────────────────────────────────

function getLineHeight(el: HTMLElement): number {
  const lh = parseFloat(getComputedStyle(el).lineHeight)
  return isNaN(lh) ? parseFloat(getComputedStyle(el).fontSize) * 1.5 : lh
}

function createMeasureEl(el: HTMLElement, width: number): HTMLDivElement {
  const cs = getComputedStyle(el)
  const div = document.createElement('div')
  div.style.cssText = `
    position: absolute;
    visibility: hidden;
    pointer-events: none;
    width: ${width}px;
    font-size: ${cs.fontSize};
    font-family: ${cs.fontFamily};
    font-weight: ${cs.fontWeight};
    line-height: ${cs.lineHeight};
    letter-spacing: ${cs.letterSpacing};
    word-break: ${cs.wordBreak};
    white-space: ${cs.whiteSpace};
  `
  document.body.appendChild(div)
  return div
}

// ─── 截断计算 ────────────────────────────────────────────────

function calcTruncation() {
  const el = containerRef.value
  if (!el || expanded.value) return

  const cs = getComputedStyle(el)
  const width = el.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight)
  if (width <= 0) return

  const lineHeight = getLineHeight(el)
  const maxHeight = lineHeight * props.maxLines
  const measureEl = createMeasureEl(el, width)

  // 用 innerHTML 量高,与实际渲染一致
  measureEl.innerHTML = props.text
  const fullHeight = measureEl.scrollHeight

  if (fullHeight <= maxHeight) {
    document.body.removeChild(measureEl)
    isTruncated.value = false
    truncatedHtml.value = props.text
    return
  }

  isTruncated.value = true

  // 二分搜索操作纯文本字符数
  const plain = htmlToPlainText(props.text)
  const suffix = `...${props.expandText}x` // 占位 x 抵消 ml-0.5 偏差

  let lo = 0
  let hi = plain.length

  while (lo < hi) {
    const mid = Math.floor((lo + hi + 1) / 2)
    const slicedHtml = sliceHtmlByVisibleLen(props.text, mid)
    // 把 suffix 注入到最后一个块级容器内部,量测才会跟实际渲染一致
    measureEl.innerHTML = appendInsideLastBlock(slicedHtml, suffix)
    if (measureEl.scrollHeight <= maxHeight) {
      lo = mid
    } else {
      hi = mid - 1
    }
  }

  document.body.removeChild(measureEl)
  truncatedHtml.value = sliceHtmlByVisibleLen(props.text, lo)
}

// ─── 生命周期 & 侦听 ─────────────────────────────────────────

const debouncedCalc = debounce(calcTruncation, 100)
let resizeObserver: ResizeObserver | null = null

onMounted(() => {
  nextTick(() => {
    calcTruncation()
    if (containerRef.value) {
      resizeObserver = new ResizeObserver(debouncedCalc)
      resizeObserver.observe(containerRef.value)
    }
  })
})

onUnmounted(() => {
  resizeObserver?.disconnect()
  debouncedCalc.cancel()
})

watch(
  () => [props.text, props.maxLines],
  () => {
    expanded.value = false
    nextTick(calcTruncation)
  },
)

// ─── 展开 / 收起 ─────────────────────────────────────────────

function expand() {
  expanded.value = true
}

function collapse() {
  expanded.value = false
  nextTick(calcTruncation)
}

// ─── 最终渲染 HTML ───────────────────────────────────────────

/**
 * 把按钮 HTML 注入到内容末尾。
 * 展开态:全文 + 收起按钮
 * 收起态:截断 HTML + ...展开按钮
 */
const btnClass = computed(() =>
  `inline ml-0.5 cursor-pointer bg-transparent border-none p-0 [font-family:inherit] [font-size:inherit] [line-height:inherit]`,
)

const renderedHtml = computed(() => {
  if (expanded.value) {
    const collapseBtn =
      `<button class="${btnClass.value} ${props.collapseClass}"
               onclick="this.dispatchEvent(new CustomEvent('collapse', { bubbles: true }))"
       >${props.collapseText}</button>`
    return appendInsideLastBlock(props.text, collapseBtn)
  }
  if (isTruncated.value) {
    const expandBtn =
      `...<button class="${btnClass.value} ${props.expandClass}"
                  onclick="this.dispatchEvent(new CustomEvent('expand', { bubbles: true }))"
       >${props.expandText}</button>`
    return appendInsideLastBlock(truncatedHtml.value, expandBtn)
  }
  return props.text
})
</script>

<template>
  <div
    ref="containerRef"
    v-html="renderedHtml"
    @expand="expand"
    @collapse="collapse"
  />
</template>
相关推荐
掘金者阿豪1 小时前
把业务数据变成共享仪表盘:Metabase可视化与远程访问实践
前端·后端
kyriewen1 小时前
折腾了半年 AI 编程工作流,最后发现效率瓶颈是桌上那块屏幕
前端·javascript·ai编程
蜗牛前端2 小时前
codex 全流程开发上线的高颜值礼簿小程序
前端·微信小程序
大龄秃头程序员2 小时前
我在图文流 App 里落地双层缓存、弱网降级与 OOM 治理
前端
老王以为2 小时前
React Renderer 分离的多平台架构
前端·react native·react.js
hunterandroid2 小时前
Kotlin Coroutines 与 Flow:让异步任务更清晰
前端
Bigger3 小时前
从零搭建 AI 代码审查服务:一份前端也能看懂的 Python 学习笔记
前端·ci/cd·ai编程
lichenyang4533 小时前
JSAPI、NAPI、Biz、Imp:ASCF Demo 如何真正调用系统能力和 C++ 能力
前端
lichenyang4534 小时前
IPC、JSVM、UIThread、libuv:ASCF 架构图里最容易混的几个词
前端
用户059540174464 小时前
Redis记忆存储故障恢复测试踩坑实录:手动测试让我漏掉了2个一致性Bug
前端·css