基于 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...

相关推荐
我要洋人死44 分钟前
导航栏及下拉菜单的实现
前端·css·css3
科技探秘人1 小时前
Chrome与火狐哪个浏览器的隐私追踪功能更好
前端·chrome
科技探秘人1 小时前
Chrome与傲游浏览器性能与功能的深度对比
前端·chrome
JerryXZR1 小时前
前端开发中ES6的技术细节二
前端·javascript·es6
七星静香1 小时前
laravel chunkById 分块查询 使用时的问题
java·前端·laravel
q2498596931 小时前
前端预览word、excel、ppt
前端·word·excel
小华同学ai1 小时前
wflow-web:开源啦 ,高仿钉钉、飞书、企业微信的审批流程设计器,轻松打造属于你的工作流设计器
前端·钉钉·飞书
Gavin_9151 小时前
【JavaScript】模块化开发
前端·javascript·vue.js
懒大王爱吃狼2 小时前
Python教程:python枚举类定义和使用
开发语言·前端·javascript·python·python基础·python编程·python书籍
逐·風6 小时前
unity关于自定义渲染、内存管理、性能调优、复杂物理模拟、并行计算以及插件开发
前端·unity·c#