基于 Canvas 的多行文本溢出方案

说到文本溢出,大家应该都不陌生,中文网络上的文章翻来覆去就是下面3种方法:

单行文本溢出

这是日常开发中用的最多的,核心代码如下:

css 复制代码
p {
  width: 300px;
  overflow: hidden; 
  white-space: nowrap; /*文本不会换行*/
  text-overflow: ellipsis;  /*当文本溢出包含元素时,以省略号表示超出的文本*/
}

但这个方法只对单行文本生效,如果我们想要对多行文本实现溢出控制,那要如何做呢?

多行文本溢出

总的来说,有2种思路,一种是基于 CSS 里的 box-orient(已废弃),另一种是基于伪元素。

基于 box-orient

css 复制代码
p {
  width: 300px;
  overflow: hidden; /*将对象作为弹性伸缩盒子模型显示*/
  display: -webkit-box; /*设置子元素排列方式*/
  -webkit-box-orient: vertical; /*设置显示的行数,多出的部分会显示为...*/
  -webkit-line-clamp: 3;
}

这里用到了box-orient这个属性以及webkit-line-clamp,但是这个方法其实是不推荐在生产环境使用的,因为box-orient这个属性现在已经不推荐使用了,详见 box-orient的官方描述

基于伪元素

css 复制代码
p {
  position: relative;
  line-height: 1.2em;
  max-height: 3.6em;
  width: 300px; 
  text-align: justify; /*设置文本为两端对齐*/
  overflow: hidden;
}

p ::after {
  content: '...';
  position: absolute;
  bottom: 0;
  right: 0; 
  width: 1em; /*将省略号的大小设置为1个字体大小*/
  background: #fff; /*设置背景,将最后一个字覆盖掉*/
}

可以看到这种方法主要是通过在段落的末尾添加1个伪元素,来覆盖最后的文字,但是这种方法无法动态地依据文本的长度来展示溢出元素,所以我们可以在这里做一些 hack。

效果图如下:

动态适应

因为掘金无法展示视频,所以视频大家可以到知乎上看

所以如果我们想要实现动态适应,要怎么做呢?这里给出 mxclsh 大佬的一种基于float属性的方法(细节见文末的"参考资料"),基本原理:

有个三个盒子 div,粉色盒子左浮动,浅蓝色盒子和黄色盒子右浮动,

  1. 当浅蓝色盒子的高度低于粉色盒子,黄色盒子仍会处于浅蓝色盒子右下方。
  2. 如果浅蓝色盒子文本过多,高度超过了粉色盒子,则黄色盒子不会停留在右下方,而是掉到了粉色盒子下。

那么我们可以将黄色盒子进行相对定位,将内容溢出的黄色盒子移动到文本内容右下角,而未溢出的则会被移到外太空去了。代码如下:

HTML

html 复制代码
 <div class="wrap">
    <div class="text">这是一段文字。这是一段文字。这是一段文字。这是一段文字。这是一段文字。这是一段文字。这是一段文字。这是一段文字。这是一段文字。这是一段文字。Lorem ipsum dolor sit
        amet,
        consectetur adipisicing elit. Dignissimos labore sit vel itaque
        delectus atque quos magnam assumenda quod architecto perspiciatis animi.</div>
</div>

CSS

css 复制代码
.wrap {
  height: 40px;
  line-height: 20px;
  overflow: hidden;
}

.wrap .text {
  float: right;
  margin-left: -5px;
  width: 100%;
  background-color: rgb(30, 195, 232);
  word-break: break-all;
}

.wrap::before {
  float: left;
  width: 5px;
  content: '';
  height: 40px;
}

.wrap::after {
  float: right;
  content: '...';
  height: 20px;
  line-height: 20px;
  /* 为三个省略号的宽度 */
  width: 3em;
  /* 使盒子不占位置 */
  margin-left: -3em;
  /* 移动省略号位置 */
  position: relative;
  left: 100%;
  top: -20px;
  padding-right: 5px;
  /* White background */
  background-color: rgb(202, 225, 24);
  /* Blur effect */
  backdrop-filter: blur(10px);
}
 

但是如果我们不仅想要多行文本不仅能做到动态适应,且能做到自定义溢出元素(例如插入1个 emoij 或图片),那该怎么办呢?这个时候我们就要祭出 Canvas 这个大杀器。

基于 Canvas 来实现多行文本溢出

这里我们需要跳出已有的思维禁锢,考虑用新的思路来做文本截断。

核心:用 canvas 的 measureText 来计算文本的理论最大长度,然后结合指定的最大行数和单行文本的宽度,通过二分算法来找到真正截断应该发生的地方,并展示自定义溢出元素

具体用法大概是这样:

jsx 复制代码
<MagicText elementId="magic-123" text-maxline={2} className="multiple-text-line">
    这是一段很长的文本。这是一段很长的文本。这是一段很长的文本。这是一段很长的文本。这是一段很长的文本。这是一段很长的文本。这是一段很长的文本。这是一段很长的文本。这是一段很长的文本。这是一段很长的文本。这是一段很长的文本。这是一段很长的文本。这是一段很长的文本。这是一段很长的文本。这是一段很长的文本。这是一段很长的文本。这是一段很长的文本。这是一段很长的文本。
    <MagicInlineTruncation>
        <Img src={picSrc} className="truncation-image">
        <span>文本已经溢出啦</span>
    </MagicInlineTruncation>
</MagicText>

下面给出伪代码,具体的实现大家可以尽情发挥,这里是有很多可以优化的空间的(づ ̄3 ̄)づ╭❤~

tsx 复制代码
const MagicText = (props: MagicTextProps) => {
  useEffect(() => {
    handleTruncation(textMaxLine, props.elementId!);
  }, [props.style, props.children]);

  return (
    <span data-tag="magic-text" data-element-id={props.elementId} style={props.style}>
      <span style={{ width: '100%' }}>{props.children}</span>
    </span>
  );
};


function handleTruncation(textMaxLine: number, elementId: string) {
  const ele = document.querySelector(`span[data-element-id='${elementId}']`);
  if (!ele) {
    return;
  }

  // check whether "magic-inline-truncation" exists in children. If it does, then we should do truncation
  const nestedChild = ele.children[0].childNodes;
  let inlineTruncationElement;
  Array.from(nestedChild).some((item: any) => {
    if (item.attributes?.['data-tag'].value === 'magic-inline-truncation') {
      inlineTruncationElement = item;
      return true;
    }
  });

  const truncationWidth =
    inlineTruncationElement?.getBoundingClientRect().width ?? 0;
  // if truncationWidth <= 0, then we should not do truncation
  if (truncationWidth <= 0) {
    return;
  }

  //! try to calculate the max width with "magic-inline-truncation"
  // principle:
  //  1. get the width of magic-text
  //  2. if width is not set, get width from its parent
  const widthFromStyle = window.getComputedStyle(ele).width;
  // it can be optimized later
  const lineWidth: number =
    widthFromStyle === ''
      ? Math.floor(ele.getBoundingClientRect().width)
      : Number(widthFromStyle.slice(0, -2));
  const maxLine = textMaxLine == 0 ? 1 : textMaxLine;
  const maxTotalWidth = Math.floor(lineWidth * maxLine); // get the maximum width
  const content = String(ele.children[0].childNodes[0].textContent); // read the text content
  const textStyle = getCanvasFont(ele);
  const totalTextWidth = getTextWidth(content, textStyle); // calculate the text width with canvas
  const targetTotalWidth = maxTotalWidth - truncationWidth; // the expected width
  if (totalTextWidth >= maxTotalWidth) {
    // try to do binary search to find the right text
    const newContent = binarySearch(
      content.split(''),
      targetTotalWidth,
      textStyle
    );
    nestedChild[0].nodeValue = newContent;
  } else {
    // hide the truncation
    inlineTruncationElement.style.display = 'none';
  }
}

// Try to find the exact position in the text where the truncation should start
function binarySearch(
  text: string[],
  targetWidth: number,
  textStyle: string
): string {
  let left = 0;
  let right = text.length - 1;
  const DELTA_WIDTH = 5; // It represents the width of single character and it use to judge critical conditions

  while (left <= right) {
    const mid = Math.floor(left + (right - left) / 2);
    const searchWidthText = text.slice(0, mid + 1).join('');
    const textWidth = getTextWidth(searchWidthText, textStyle);
    if (isHitTarget(targetWidth, textWidth, DELTA_WIDTH)) {
      return searchWidthText;
    } else if (textWidth < targetWidth) {
      left = mid + 1;
    } else if (textWidth > targetWidth) {
      right = mid - 1;
    }
  }

  return text.join('');
}

function isHitTarget(target: number, source: number, delta: number) {
  return Math.abs(target - source) <= delta;
}


interface MagicTextProps {
  /**
* maximum number of lines for text
*/
  'text-maxline'?: string;

  /**
* The logic of text truncation when text overflows
* clip: directly truncate
* tail: add ellipsis to the end
*/
  'ellipsize-mode'?: 'clip' | 'tail';
}

计算文本具体有多宽的核心代码如下:

tsx 复制代码
 /**
* Uses canvas.measureText to compute and return the width of the given text of given font in pixels.
*
* @param { String } text The text to be rendered.
* @param { String } font The css font descriptor that text is to be rendered with (e.g. "bold 14px verdana").
*
*/
function getTextWidth(text: string, font: string): number {
  // re-use canvas object for better performance
  let canvas;
  if (getTextWidth.prototype.canvas) {
    canvas = getTextWidth.prototype.canvas;
  } else {
    canvas = document.createElement('canvas');
  }
  const context = canvas.getContext('2d');
  context.font = font;
  const metrics = context.measureText(text);
  return metrics.width;
}

function getCssStyle(element: Element, prop: string) {
  return window.getComputedStyle(element, null).getPropertyValue(prop);
}

// currently, we calculate text width using only "font-size", "font-family", and "font-weight", but
// we can consider more styles that impact text width later on
function getCanvasFont(el: Element = document.body): string {
  const fontWeight =
    getCssStyle(el, 'font-weight') || getCssStyle(document.body, 'normal');
  const fontSize =
    getCssStyle(el, 'font-size') || getCssStyle(document.body, 'font-size');
  const fontFamily =
    getCssStyle(el, 'font-family') || getCssStyle(document.body, 'font-family');

  return `${fontWeight} ${fontSize} ${fontFamily}`;
}

总结

几种方式的优缺点和特点如下:

text-overflow 伪元素 伪元素+float 基于Canvas
支持单行文本溢出
支持多行文本溢出
支持自适应
支持自定义溢出的元素
支持自定义最大行数
性能 一般

参考资料

blog.csdn.net/mxclsh/arti...

stackoverflow.com/questions/1...

相关推荐
xiao-xiang11 分钟前
jenkins-通过api获取所有job及最新build信息
前端·servlet·jenkins
C语言魔术师28 分钟前
【小游戏篇】三子棋游戏
前端·算法·游戏
匹马夕阳2 小时前
Vue 3中导航守卫(Navigation Guard)结合Axios实现token认证机制
前端·javascript·vue.js
你熬夜了吗?2 小时前
日历热力图,月度数据可视化图表(日活跃图、格子图)vue组件
前端·vue.js·信息可视化
桂月二二8 小时前
探索前端开发中的 Web Vitals —— 提升用户体验的关键技术
前端·ux
hunter2062069 小时前
ubuntu向一个pc主机通过web发送数据,pc端通过工具直接查看收到的数据
linux·前端·ubuntu
qzhqbb9 小时前
web服务器 网站部署的架构
服务器·前端·架构
刻刻帝的海角9 小时前
CSS 颜色
前端·css
九酒10 小时前
从UI稿到代码优化,看Trae AI 编辑器如何帮助开发者提效
前端·trae
浪浪山小白兔10 小时前
HTML5 新表单属性详解
前端·html·html5