<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, nextTick } 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 truncatedText = ref(props.text)
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)
measureEl.textContent = props.text
const fullHeight = measureEl.scrollHeight
if (fullHeight <= maxHeight) {
document.body.removeChild(measureEl)
isTruncated.value = false
truncatedText.value = props.text
return
}
isTruncated.value = true
const suffix = `...${props.expandText}x`
let lo = 0
let hi = props.text.length
while (lo < hi) {
const mid = Math.floor((lo + hi + 1) / 2)
measureEl.textContent = props.text.slice(0, mid) + suffix
if (measureEl.scrollHeight <= maxHeight) {
lo = mid
} else {
hi = mid - 1
}
}
document.body.removeChild(measureEl)
truncatedText.value = props.text.slice(0, 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)
} </script>
<template>
<div ref="containerRef">
<template v-if="!expanded">{{ truncatedText }}<template v-if="isTruncated">...<button
:class="expandClass"
class="inline ml-0.5 cursor-pointer bg-transparent border-none p-0 [font-family:inherit] [font-size:inherit] [line-height:inherit]"
@click="expand"
>{{ expandText }}</button></template></template>
<template v-else>{{ text }}<button
:class="collapseClass"
class="inline ml-0.5 cursor-pointer bg-transparent border-none p-0 [font-family:inherit] [font-size:inherit] [line-height:inherit]"
@click="collapse"
>{{ collapseText }}</button></template>
</div>
</template>

