如何给文本域(textarea)加上"行号"显示-同步滚动、修改等操作

写在开头

哈喽,各位好呀!😀

此时此刻,小编正在广州经历最恐怖的天气,回南天🤢!!!

不怕冷不怕热,就怕这回南天,到处湿漉漉的,租房直接变成水帘洞。。。

唉,不扯远了,如下是本次分享的最终效果:

各位按需食用。🤡

正文

布局与样式

页面布局和样式比较简单,随便瞧瞧就行,不是重点。😋

html 复制代码
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>文本域显示行号</title>
  <style>
    .container {
      display: flex;
      border: 1px solid rgb(203, 213, 225);
      overflow: hidden;
      width: 400px; 
    }
    .numbers {
      /** 尽量不要给行号的容器设置属于,它的属性可以从textarea身上取 **/
      border-right: 1px solid rgb(203, 213, 225);
      overflow: hidden;
      text-align: center;
      box-sizing: border-box;
    }
    .textarea {
      width: 370px;
      padding: 5px;
      border: none;
      outline: none;
      width: 100%;
      font-size: 20px;
      box-sizing: border-box;
      /* 很关键: 可以看看下面的补充说明 */
      resize: vertical;
      min-height: 10rem;
      max-height: 20rem;
    }
    /** 美化滚动条 **/
    ::-webkit-scrollbar {
      background: transparent;
      height: 8px;
      width: 8px;
    }
    ::-webkit-scrollbar-thumb {
      border-radius: 1;
      background: rgb(148 163 184);
    }
    ::-webkit-scrollbar-track {
      background: transparent;
    }
  </style>
</head>
<body>
  <div class="container">
    <div class="numbers"></div>
    <textarea class="textarea"></textarea>
  </div>
</body>
</html>

我们尽量不要去给行号的容器(numbers)设置样式,可能会影响内容与行号的对齐,它的样式会从 textarea 元素身上同步过来,比如字体、字体大小、内边距(padding)等等。

如果,你还发现有细微的内容和行号对不齐的情况,欢迎留言,或者请去检查相关元素的 borderbox-sizing 等样式属性。(别问我怎么知道的🙉)

关于文本域(textarea)禁止拖动调整大小的说明:

众所周知, textarea 元素是允许用户手动拖动调整大小的,但是,有时这一操作可能会破坏页面的整体布局,为此我们又不得不对其进行一些拖动上的限制。

禁止 textarea 元素拖动可以使用 resize 属性,它主要有 noneverticalhorizontal 等值。

为什么在这里说这个呢?主要是很多人可能会错误使用该属性,动不动上来就是 resize: none 属性,要么造成用户完全无法调整大小,要么造成回显文案无法完整显示等等糟糕的用户体验。

正确做法💡:

javascript 复制代码
textarea {
  resize: vertical; /* 根据实际的业务选择属性值 */
  /* 控制文本域的高度,根据实际需求调整 */
  min-height: 5rem;
  max-height: 20rem;
}

显示行号

第二步,我们来初始化行号的显示。为了测试效果,我们先写死一些内容在 textarea 元素中,这里小编找了李白的将进酒作为测试内容。

(卑微的打工人要及时行乐,劳逸结合,人生得意须尽欢,莫使金樽空对月,爱咋滴咋滴。😆)

html 复制代码
<textarea class="textarea">
将进酒
唐代:李白
君不见,黄河之水天上来,奔流到海不复回。
君不见,高堂明镜悲白发,朝如青丝暮成雪。
人生得意须尽欢,莫使金樽空对月。
天生我材必有用,千金散尽还复来。
烹羊宰牛且为乐,会须一饮三百杯。
岑夫子,丹丘生,将进酒,杯莫停。
与君歌一曲,请君为我倾耳听。(倾耳听 一作:侧耳听)
钟鼓馔玉不足贵,但愿长醉不愿醒。(不足贵 一作:何足贵;不愿醒 一作:不复醒)
古来圣贤皆寂寞,惟有饮者留其名。(古来 一作:自古;惟 通:唯)
陈王昔时宴平乐,斗酒十千恣欢谑。
主人何为言少钱,径须沽取对君酌。
五花马,千金裘,呼儿将出换美酒,与尔同销万古愁。
</textarea>

显示行号关键点在于要确定内容有多少行数,然后通过行数去动态渲染出行号的数量。

为此,我们需要通过将内容拆分为不同的行来确定文本域中的行数,这里我们先粗略以"换行符"来拆分:

html 复制代码
<script>
  // 将逻辑都放在DOMContentLoaded事件下,防止操作DOM出现错误
  document.addEventListener('DOMContentLoaded', () => {
    const textarea = document.querySelector('.textarea');
    const numbers = document.querySelector('.numbers');

    function initLineNumbers() {
      // 把内容按换行符划分行数
      const lines = textarea.value.split('\n');
      // 根据行数动态生成行号的数量
      const lineDoms = Array.from({
        length: lines.length,
      }, (_, i) => `<div>${i + 1}</div>`);
      numbers.innerHTML = lineDoms.join('');
    }
    initLineNumbers();
  });
</script>

虽然还有点丑😁,但是没关系我们继续来美化、完善它。

内容与行号对齐

第三步,我们需要来给行号的容器(numbers)设置样式,让行号与内容齐平,而需要设置的这些样式可以从 textarea 元素身上动态取,这样能避免我们去维护两个元素的基础样式一致。

javascript 复制代码
// 获取文本域的所有样式
const textareaStyles = window.getComputedStyle(textarea);
// 取我们需要的样式
[
  'fontFamily', 'fontSize', 'fontWeight', 
  'letterSpacing', 'lineHeight', 'padding',
].forEach(property => {
  numbers.style[property] = textareaStyles[property];
});

对齐后,就稍微好看一点啦。😀

内容拆分🍊

前面,我们说过只是粗略以"换行符"来拆分内容,为什么说是"粗略"呢❓

可以仔细看上面截图,"将进酒"的内容大概能让我们生成15个行号,但这明显和内容行数是对应不上的,里面有一些句子是占据了多行的,这就造成了内容多,行号少的尴尬局面了。😳

那么,如何来解决这个问题呢❓

内容是由句子来组成的,如果我们能知道每个句子占据的行数,再全部累加起来,用这个总数再去生成全部行号,这个结果才是准确的。

根据这个想法,我们看看在代码中是如何来解决的:

javascript 复制代码
function initLineNumbers() {
  // 获取全部的行号
  const lines = calcLines();
  // 空行用'&nbsp;'占位
  const lineDoms = Array.from({
    length: lines.length,
  }, (_, i) => `<div>${lines[i] || '&nbsp;'}</div>`);
  numbers.innerHTML = lineDoms.join('');
}

/* 创建canvas元素,来辅助计算,主要是使用canvas.measureText方法 */
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
// 保持canvas的画笔与字体设置一致
const font = `${textareaStyles.fontSize} ${textareaStyles.fontFamily}`;
context.font = font;

/**
 * @name 计算一个句子在目标容器中占据多少行
 * @param { string } sentence
 * @param { number } width 目标容器的宽度 --- 文本域的内容宽度
 * @returns  { number }
 */
function calcStringLines(sentence, width) {
  if (!width) return 0;
  // 将句子拆分,如果是纯英文句子可以使用.split(' ')进行拆分提升效率
  const words = sentence.split('');
  // 一个句子占据的行数
  let lineCount = 0;
  
  let currentLine = '';
  for (let i = 0; i < words.length; i++) {
    // 获取被测量文本的TextMetrics对象,主要拿width
    const wordWidth = context.measureText(words[i]).width;
    const lineWidth = context.measureText(currentLine).width;
    if (lineWidth + wordWidth > width) {
      // 当前的句子累计的词语已经大于目标容器的宽度了,要多占一行
      lineCount++;
      // 已经占一行了,要清空重新累加
      currentLine = words[i];
    } else {
      currentLine += words[i];
    }
  }
  // 最后剩余的占一行
  if (currentLine.trim() !== '') lineCount++;
  return lineCount;
}

function calcLines() {
  // 按"换行符"分隔句子
  const lines = textarea.value.split('\n');
  // textarea宽度
  const textareaWidth = textarea.getBoundingClientRect().width;
  // textarea滚动条宽度,没有则为0
  const textareaScrollWidth = textareaWidth - textarea.clientWidth;
  // textarea内边距,转成数字
  const parseNumber = (v) => v.endsWith('px') ? parseInt(v.slice(0, -2), 10) : 0;
  const textareaPaddingLeft = parseNumber(textareaStyles.paddingLeft);
  const textareaPaddingRight = parseNumber(textareaStyles.paddingRight);
  // textarea的内容宽度
  const textareaContentWidth = textareaWidth - textareaPaddingLeft -   textareaPaddingRight  - textareaScrollWidth;
  // 获取每个句子占多少行,数组形式记录
  const numLines = lines.map(lineString => calcStringLines(lineString, textareaContentWidth));
  // 将每个句子占据的行数转成对应的行号与空格
  let lineNumbers = [];
  let i = 1;
  while (numLines.length > 0) {
    const numLinesOfSentence = numLines.shift();
    lineNumbers.push(i);
    if (numLinesOfSentence > 1) {
      Array(numLinesOfSentence - 1)
        .fill('')
        .forEach((_) => lineNumbers.push(''));
    }
    i++;
  }
  return lineNumbers;
}

// 注意放在最下面调用,上面有dom的操作
initLineNumbers();

calcStringLines 函数:

  • 原理就是把一个句子拆成多个词语,再去检测每个词语的宽度,再慢慢去叠加宽度,然后与目标容器的宽度作比较。
  • 测量文本的宽度使用了 context.measureText API,获取的 TextMetrics 对象如下:

calcLines 函数:

  • 主要作用是确定 textarea 的"内容宽度",最开始我们设置了它的宽度是 width: 370px; ,但它的内容宽度需要减去内边距与滚动条的宽度。
  • 还有就是把生成的每个句子占据的行数数组([1, 1, 2, 2, 2, ...])转成行号的数组([1, 2, 3, '', 4, '', 5, ...])的形式。

总的是增加两个函数,全部代码小编都写上详细的注释了,可不能说看不懂啦。👻

这下就能完美让行号根据内容行数来渲染了,当然,你也可以不要空的行号('&nbsp;')来占位,顺序排列行号也可以,这点就你自己琢磨囖。💀

文本域尺寸变化

然而,还没完,现在整体布局看来很奇怪,一边长一边短的,原因是两边的高度没有同步好,让我们继续来完善它。

javascript 复制代码
const ro = new ResizeObserver(() => {
  const rect = textarea.getBoundingClientRect();
  numbers.style.height = `${rect.height}px`;
  initLineNumbers();
});
ro.observe(textarea);

由于文本域能手动调整大小,我们使用 ResizeObserver API来监听它的变化,然后把高度同步给行号的容器。

在外面的 initLineNumbers() 方法不需要调用了,ResizeObserver API初始化会调用一次,而且,两者产生的文本域高度是不一样的结果❗

滚动同步

从上面动图可以看到内容滚动的时候行号还不能同步,我们再来瞧瞧这个问题要如何解决。

javascript 复制代码
textarea.addEventListener('scroll', () => {
  numbers.scrollTop = textarea.scrollTop;
});

很简单,就加一个监听滚动即可,So Easy 👻👻👻

内容修改

接下来还有最后一个问题,当增加或者删除文本域内容的时候,如何保持行号的同步呢❓

其实也简单,就不卖关子了,直接上代码:

javascript 复制代码
textarea.addEventListener('input', () => {
  initLineNumbers();
});

完整源码

html 复制代码
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>文本域显示行号</title>
  <style>
    .container {
      display: flex;
      border: 1px solid rgb(203 213 225);
      overflow: hidden;
      width: 400px;
    }
    .numbers {
      flex: 1;
      border-right: 1px solid rgb(203 213 225);
      overflow: hidden;
      text-align: center;
      box-sizing: border-box;
    }
    .textarea {
      width: 370px;
      padding: 5px;
      border: none;
      outline: none;
      font-size: 20px;
      resize: vertical;
      min-height: 10rem;
      max-height: 20rem;
      overflow-x: hidden;
      box-sizing: border-box;
    }
    ::-webkit-scrollbar {
      background: transparent;
      height: 8px;
      width: 8px;
    }
    ::-webkit-scrollbar-thumb {
      border-radius: 1;
      background: rgb(148 163 184);
    }
    ::-webkit-scrollbar-track {
      background: transparent;
    }
  </style>
</head>
<body>
  <div class="container">
    <div class="numbers"></div>
    <textarea class="textarea"></textarea>
  </div>
<script>
  document.addEventListener('DOMContentLoaded', () => {
    const textarea = document.querySelector('.textarea');
    const numbers = document.querySelector('.numbers');
    function initLineNumbers() {
      const lines = calcLines();
      const lineDoms = Array.from({
        length: lines.length,
      }, (_, i) => `<div>${lines[i] || '&nbsp;'}</div>`);
      numbers.innerHTML = lineDoms.join('');
    }
    const textareaStyles = window.getComputedStyle(textarea);
    [
      'fontFamily', 'fontSize', 'fontWeight',
      'letterSpacing', 'lineHeight', 'padding',
    ].forEach((property) => {
      numbers.style[property] = textareaStyles[property];
    });
    const canvas = document.createElement('canvas');
    const context = canvas.getContext('2d');
    const font = `${textareaStyles.fontSize} ${textareaStyles.fontFamily}`;
    context.font = font;
    function calcStringLines(sentence, width) {
      if (!width) return 0;
      const words = sentence.split('');
      let lineCount = 0;
      let currentLine = '';
      for (let i = 0; i < words.length; i++) {
        const wordWidth = context.measureText(words[i]).width;
        const lineWidth = context.measureText(currentLine).width;
        if (lineWidth + wordWidth > width) {
          lineCount++;
          currentLine = words[i];
        } else {
          currentLine += words[i];
        }
      }
      if (currentLine.trim() !== '') lineCount++;
      return lineCount;
    }
    function calcLines() {
      const lines = textarea.value.split('\n');
      const textareaWidth = textarea.getBoundingClientRect().width;
      const textareaScrollWidth = textareaWidth - textarea.clientWidth;
      const parseNumber = (v) => v.endsWith('px') ? parseInt(v.slice(0, -2), 10) : 0;
      const textareaPaddingLeft = parseNumber(textareaStyles.paddingLeft);
      const textareaPaddingRight = parseNumber(textareaStyles.paddingRight);
      const textareaContentWidth = textareaWidth - textareaPaddingLeft - textareaPaddingRight - textareaScrollWidth;
      const numLines = lines.map(lineString => calcStringLines(lineString, textareaContentWidth));
      let lineNumbers = [];
      let i = 1;
      while (numLines.length > 0) {
        const numLinesOfSentence = numLines.shift();
        lineNumbers.push(i);
        if (numLinesOfSentence > 1) {
          Array(numLinesOfSentence - 1)
            .fill('')
            .forEach((_) => lineNumbers.push(''));
        }
        i++;
      }
      return lineNumbers;
    }
    const ro = new ResizeObserver(() => {
      const rect = textarea.getBoundingClientRect();
      numbers.style.height = `${rect.height}px`;
      initLineNumbers();
    });
    ro.observe(textarea);
    textarea.addEventListener('scroll', () => {
      numbers.scrollTop = textarea.scrollTop;
    });
    textarea.addEventListener('input', () => {
      initLineNumbers();
    });
  });
</script>
</body>
</html>

至此,本篇文章就写完啦,撒花撒花。

希望本文对你有所帮助,如有任何疑问,期待你的留言哦。

老样子,点赞+评论=你会了,收藏=你精通了。

相关推荐
JUNAI_Strive_ving10 分钟前
番茄小说逆向爬取
javascript·python
看到请催我学习19 分钟前
如何实现两个标签页之间的通信
javascript·css·typescript·node.js·html5
twins352039 分钟前
解决Vue应用中遇到路由刷新后出现 404 错误
前端·javascript·vue.js
qiyi.sky1 小时前
JavaWeb——Vue组件库Element(3/6):常见组件:Dialog对话框、Form表单(介绍、使用、实际效果)
前端·javascript·vue.js
煸橙干儿~~1 小时前
分析JS Crash(进程崩溃)
java·前端·javascript
哪 吒1 小时前
华为OD机试 - 几何平均值最大子数(Python/JS/C/C++ 2024 E卷 200分)
javascript·python·华为od
安冬的码畜日常1 小时前
【D3.js in Action 3 精译_027】3.4 让 D3 数据适应屏幕(下)—— D3 分段比例尺的用法
前端·javascript·信息可视化·数据可视化·d3.js·d3比例尺·分段比例尺
l1x1n02 小时前
No.3 笔记 | Web安全基础:Web1.0 - 3.0 发展史
前端·http·html
Q_w77422 小时前
一个真实可用的登录界面!
javascript·mysql·php·html5·网站登录
昨天;明天。今天。2 小时前
案例-任务清单
前端·javascript·css