You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

React实现受限输入框:30字符换行、不可编辑段及光标跳转修复

问题描述

需要开发支持以下特性的文本输入域:

  • 展示后端返回的默认文本,默认文本中的指定片段不可编辑
  • 单行输入字符数达到30时自动按词拆分换行
    现有实现存在明确缺陷:触发自动换行逻辑时,输入光标会直接跳转到全部文本末尾,无法在当前输入位置继续正常编辑。

现有实现代码:

import React, { useEffect } from "react";
import styles from "./TextEditor.module.css";

const TextEditor = (props) => {
  var limit = 80000; //height limit
  let defaultvalueArr = props.data.variables.map((vari, index) => {
    return vari.texts.map((p, index) => {
      return p;
    });
  });
  useEffect(() => {
    const box = document.getElementById(props.title);
    var charlimit = 30; // char limit per line
    var space;
    box.onkeyup = function () {
      var lines = box.value.split("\n");
      console.log(box.value, "box.value");
      for (var i = 0; i < lines.length; i++) {
        if (lines[i].length <= charlimit) continue;
        var j = 0;
        space = charlimit;
        while (j++ <= charlimit) {
          if (lines[i].charAt(j) === " ") space = j;
        }
        lines[i + 1] = lines[i].substring(space + 1) + (lines[i + 1] || "");
        lines[i] = lines[i].substring(0, space);
      }
      box.value = lines.slice(0, 500).join("\n");
    };
    box.oninput = function() {
      box.style.height = "";
      box.style.height = Math.min(box.scrollHeight, limit) + "px";
    };
    box.value = defaultvalueArr.join("\n");
  }, []);

  return (
    <div className={styles.TextEditor}>
      <textarea
        id={props.title}
        className={styles.TextArea}
        // rows={defaultvalueArr[0].length}
      ></textarea>
    </div>
  );
};

export default TextEditor;

实现效果参考:
文本编辑器运行效果

问题根因

直接修改textarea的value属性时,浏览器默认行为是将输入光标重置到文本末尾,原有代码在重新赋值后没有主动恢复光标位置,因此出现跳转问题。此外原有实现直接操作DOM、没有做不可编辑片段的逻辑校验,不符合React开发规范也没覆盖全部需求。

修复方案
  • 改用React受控组件模式管理输入值,通过ref获取textarea实例操作光标
  • 执行自动换行逻辑前先记录当前光标位置,处理完文本后计算换行带来的偏移量,重新赋值后主动将光标设置到正确位置
  • 对不可编辑片段增加标识(可和后端约定用{{变量名}}格式标记受保护内容),输入事件中校验光标位置,禁止修改受保护片段

修复后的核心代码参考:

import React, { useState, useRef, useEffect } from "react";
import styles from "./TextEditor.module.css";

const CHAR_LIMIT_PER_LINE = 30;
const MAX_HEIGHT = 80000;
// 匹配不可编辑片段的正则,可根据后端返回规则调整
const LOCKED_PATTERN = /\{\{.*?\}\}/g;

const TextEditor = (props) => {
  const textareaRef = useRef(null);
  // 初始化默认值
  const initValue = props.data.variables
    .map(vari => vari.texts.join("\n"))
    .join("\n");
  const [value, setValue] = useState(initValue);

  // 自动换行处理逻辑
  const handleAutoWrap = (text, cursorPos) => {
    const lines = text.split("\n");
    let newCursorPos = cursorPos;
    for (let i = 0; i < lines.length; i++) {
      if (lines[i].length <= CHAR_LIMIT_PER_LINE) continue;
      // 找最近的空格拆分,没有空格就硬拆30字符
      let splitPos = CHAR_LIMIT_PER_LINE;
      for (let j = CHAR_LIMIT_PER_LINE; j >=0; j--) {
        if (lines[i][j] === " ") {
          splitPos = j;
          break;
        }
      }
      // 计算光标偏移:如果光标在拆分位置之后,要加1(因为多了一个换行符)
      const lineStart = lines.slice(0, i).join("\n").length + (i > 0 ? 1 : 0);
      if (cursorPos > lineStart + splitPos) {
        newCursorPos += 1;
      }
      lines[i + 1] = lines[i].substring(splitPos + 1) + (lines[i + 1] || "");
      lines[i] = lines[i].substring(0, splitPos);
    }
    return {
      newText: lines.slice(0, 500).join("\n"),
      newCursorPos
    };
  };

  // 校验不可编辑片段是否被修改
  const checkLockedContent = (oldText, newText) => {
    const oldLocked = oldText.match(LOCKED_PATTERN) || [];
    const newLocked = newText.match(LOCKED_PATTERN) || [];
    // 基础校验:不可编辑片段数量和内容不能变,复杂场景可以逐段校验位置
    return JSON.stringify(oldLocked) === JSON.stringify(newLocked);
  };

  const handleInput = (e) => {
    const textarea = textareaRef.current;
    const oldValue = value;
    const cursorPos = textarea.selectionStart;
    let newValue = e.target.value;

    // 先校验不可编辑内容
    if (!checkLockedContent(oldValue, newValue)) {
      setValue(oldValue);
      // 光标放回原位
      textarea.setSelectionRange(cursorPos - 1, cursorPos - 1);
      return;
    }

    // 处理自动换行
    const { newText, newCursorPos } = handleAutoWrap(newValue, cursorPos);
    setValue(newText);

    // 处理高度自适应
    textarea.style.height = "";
    textarea.style.height = Math.min(textarea.scrollHeight, MAX_HEIGHT) + "px";

    // 下一帧恢复光标位置
    requestAnimationFrame(() => {
      textarea.setSelectionRange(newCursorPos, newCursorPos);
    });
  };

  useEffect(() => {
    // 初始化高度
    const textarea = textareaRef.current;
    textarea.style.height = Math.min(textarea.scrollHeight, MAX_HEIGHT) + "px";
  }, []);

  return (
    <div className={styles.TextEditor}>
      <textarea
        ref={textareaRef}
        id={props.title}
        className={styles.TextArea}
        value={value}
        onInput={handleInput}
      ></textarea>
    </div>
  );
};

export default TextEditor;

注意:不可编辑片段的校验逻辑可以根据后端实际返回的标记规则调整,如果后端是通过位置标记不可编辑区域,只需要把checkLockedContent方法替换为对应位置校验逻辑即可。

内容的提问来源于stack exchange,提问作者RonCode

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.30 04:15:51