

<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+1) {
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+1) {
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>