如何实现一个微信PC端富文本输入框?

微信PC端输入框支持图片、文件、文字、Emoji四种消息类型,本篇文章就这四种类型的消息输入进行实现。我们选用HTML5新增标签属性contenteditable来实现。

contenteditable属性

contenteditable是一个全局属性,表示当前元素可编辑。

该属性拥有一下三个属性值:

  • true或空字符串: 表示元素可编辑
  • false: 表示元素不可编辑
  • plaintext-only: 表示只有原始文本可编辑,禁用富文本编辑

给div元素添加contenteditable, 将元素变为可编辑的状态

html 复制代码
<div contenteditable></div>

文字输入

当鼠标聚焦在输入框内时,会有outline展示,所以要去掉该样式

html 复制代码
<!-- index.html -->
<div contenteditable class="editor"></div>
css 复制代码
.editor:focus {
    outline:none;
}

此时文本就可以正常输入了

图片输入

图片输入分为两部分

  • 截图粘贴
  • 图片文件复制粘贴

有的浏览器是直接支持截图图片的粘贴的,不过图片文件的复制粘贴就不是我们想要的效果(会把图片粘贴成一个文件的图标俺不是展示图片)。

我们这里要自己做一个图片的粘贴展示,以兼容所有的浏览器。

这里我们使用浏览器提供的粘贴事件paste来实现

paste事件函数中存在e.clipboardData.files 属性,该属性是一个数组,当该数组大于0时,就证明用户粘贴的是文件;通过判断 filetype 属性是否为image/... 来确定粘贴的内容是否为图片。

js 复制代码
const editor = document.querySelector(".editor");

/**
 * 处理粘贴图片
 * @param {File} imageFile 图片文件
 */
function handleImage(imageFile) {
  // 创建文件读取器
  const reader = new FileReader();
  // 读取完成
  reader.onload = (e) => {
    // 创建img标签
    const img = new Image();
    img.src = e.target.result;
    editor.appendChild(img);
  };

  reader.readAsDataURL(imageFile);
}

// 添加paste事件
editor.addEventListener("paste", (e) => {
  const files = e.clipboardData.files;
  const filesLen = files.length;
  console.log("files", files);
  // 粘贴内用是否存在文件
  if (filesLen > 0) {
    //禁止默认事件
    e.preventDefault();
    for (let i = 0; i < filesLen; i++) {
      const file = files[i];
      if (file.type.indexOf("image") === 0) {
        // 粘贴的文件为图片
        handleImage(file);
        continue;
      }
      // 粘贴内容是普通文件
    }
  }
});

现在就可以粘贴图片了,无论是截图的图片还是复制的文件图片都可以粘贴到文本框内了。

现在存在的问题就是,图片尺寸太大,会按原尺寸展示图片。微信输入框展示的尺寸为最大宽高150x150

css 复制代码
.image {
    max-width: 150px;
    max-height: 150px;
}

现在图片的粘贴展示部分就是解释正常的了

细心的同学注意到了,粘贴图片后光标不会向后移动,这是因为我们禁用了粘贴的默认事件,这里需要我们手动处理光标;因为我们使用了appendChild 将图片插入到了输入框的最后面,就会出现无论光标在哪里,粘贴的图片都是出现在输入框的最后面,我们希望的是在光标所在的地方粘贴内容,接下来我们处理一下光标。

光标处理

处理光标主要以来两个WebAPI , SelectionRange

  • Selection:Selection 对象表示用户选择的文本范围或插入符号的当前位置。它代表页面中的文本选区,可能横跨多个元素。通过window.getSelection() 获取 Selection 对象。 具体可查阅MDN-Selection

  • Range:Range 接口表示一个包含节点与文本节点的一部分的文档片段。可以使用 Document.createRange 方法创建 Range。也可以用 Selection对象的 getRangeAt()方法或者 Document对象的caretRangeFromPoint() 方法获取 Range 对象。具体可查阅MDN-Range

我们使用window.getSelection获取当前位置,通过 getRangeAt()获取当前选中的范围,将选中的内容删除,再把我们自己的内容插入到当前的 Range 中,就实现了文件的输入。在这时插入的文件是选中状态的,所以要将选择范围折叠到结束位置,即在粘贴文本的后面显示光标。

js 复制代码
/**
 *  将指定节点插入到光标位置
 * @param {DOM} fileDom dom节点
 */
function insertNode(dom) {
  // 获取光标
  const selection = window.getSelection();
  // 获取选中的内容
  const range = selection.getRangeAt(0);
  // 删除选中的内容
  range.deleteContents();
  // 将节点插入范围最前面添加节点
  range.insertNode(dom);
  // 将光标移到选中范围的最后面
  selection.collapseToEnd();
}
diff 复制代码
/**
 * 处理粘贴图片
 * @param {File} imageFile 图片文件
 */
function handleImage(imageFile) {
  // 创建文件读取器
  const reader = new FileReader();
  // 读取完成
  reader.onload = (e) => {
    // 创建img标签
    const img = new Image();
    img.src = e.target.result;
-   editor.appendChild(img);
+   insertNode(img);
  };
  reader.readAsDataURL(imageFile);
}

到这里粘贴到光标位置就正常了

文件输入

文件输入分为两部分粘贴文件和选择文件

  • 粘贴文件
  • 选择文件

粘贴文件

微信是以卡片的方式展示的文件,所以我们先要准备一个类似的dom结构。

js 复制代码
/**
 * 返回文件卡片的DOM
 * @param {File} file 文件
 * @returns 返回dom结构
 */
function createFileDom(file) {
  const size = file.size / 1024;
  const templte = `
          <div class="file-container">
            <img src="./assets/PDF.png" class="image" />
            <div class="title">
              <span>${file.name || "未命名文件"}</span>
              <span>${size.toFixed(1)}K</span>
            </div>
          </div>`;
  const dom = document.createElement("span");
  dom.style = "display:flex;";
  dom.innerHTML = templte;
  return dom;
}
css 复制代码
.file-container {
    height: 75px;
    width: 405px;
    box-sizing: border-box;
    border: 1px solid rgb(208, 208, 208);
    padding: 13px;
    display: flex;
    justify-content: start;
    gap: 13px;
    background-color: #ffffff;
}
.file-container .image {
    height: 100%;
    object-fit: scale-down;
}
.file-container .title {
    display: flex;
    flex-direction: column;
    gap: 2px;
}

按照微信样式卡片准备DOM的生成函数,如上。

将生成后的DOM加入到光标位置

js 复制代码
/**
 * 处理粘贴文件
 * @param {File} file 文件
 */
function handleFile(file) {
  insertNode(createFileDom(file));
}

在页面上就可以看到效果啦

可以看到以上的卡片还是存在问题的,

  • 光标位置不在卡片的最后面
  • Backspace 不会将整个卡片都删除掉

卡片的删除

因为我们的卡片是DOM,其父元素也就是输入框,开启了contenteditable ,因此它的该属性也继承了父元素的值,所以本身也是可以编辑的。分析到这里,就会涌现出一个方案------将卡片最外层 divcontenteditable 属性值设为 false

但是,设置了 contenteditable="false" 后就变成了不可编辑元素,光标在他周围就不显示了🥹🥹🥹

再接着考虑,思绪一下又回到了图片身上,我们能不能做一个一样的图片呢?答案是肯定的。

说到做图片我们又会有两种方案:

  • svg
  • canvas

这里就考虑使用SVG制作卡片。

SVG在这里就不多介绍了,直接开始

html 复制代码
<svg width="409" height="77" nsxml="http://www.w3.org/2000/svg">
  <!-- 矩形边框 -->
  <rect
    x="2"
    y="0"
    width="405"
    height="75"
    fill="none"
    stroke="rgb(208,208,208)"
  />
  <!-- 右侧文件图标 -->
  <polygon
    points="15 13,36 13,50 30,50 60,15 60"
    fill="rgb(156,193,233)"
    stroke="rgb(0,105,255)"
    stroke-width="2"
  />
  <!-- 图标折叠三角部分 -->
  <polygon points="36 13,36 30,50 30" fill="rgb(0,105,255)" />
  <!-- 文件类型 -->
  <text
    x="32"
    y="50"
    font-size="10"
    text-anchor="middle"
    fill="rgb(0,105,255)"
  >
    xmind
  </text>
  <!-- 文件名称 -->
  <text x="63" y="30" font-size="16">JavaScript.xmind</text>
  <!-- 文件大小 -->
  <text x="63" y="52" font-size="14">206.6K</text>
</svg>

然后就得到了一个卡片

现在我们只要将该卡片使用JS封装,在上传文件的时候调用生成图片就好了。

封装如下:

js 复制代码
/**
 * 返回文件卡片的DOM
 * @param {File} file 文件
 * @returns 返回dom结构
 */
function createFileDom(file) {
  const size = file.size / 1024;
  let extension = "未知文件";
  if (file.name.indexOf(".") >= 0) {
    const fileName = file.name.split(".");
    extension = fileName.pop();
  }
  const templte = `
  <rect
    x="2"
    y="0"
    width="405"
    height="75"
    fill="none"
    stroke="rgb(208,208,208)"
  />
  <polygon
    points="15 13,36 13,50 30,50 60,15 60"
    fill="rgb(156,193,233)"
    stroke="rgb(0,105,255)"
    stroke-width="2"
  />
  <polygon points="36 13,36 30,50 30" fill="rgb(0,105,255)" />
  <text
    x="32"
    y="50"
    font-size="10"
    text-anchor="middle"
    fill="rgb(0,105,255)"
  >
   ${extension}
  </text>
  <text x="63" y="30" font-size="16">${file.name || "未命名文件"}</text>
  <text x="63" y="52" font-size="14">${size.toFixed(1)}K</text>
`;
  const dom = document.createElementNS("http://www.w3.org/2000/svg", "svg");
  dom.setAttribute("width", "409");
  dom.setAttribute("height", "77");
  dom.innerHTML = templte;
  const svg = new XMLSerializer().serializeToString(dom);
  const blob = new Blob([svg], {
    type: "image/svg+xml;charset=utf-8",
  });
  const imageUrl = URL.createObjectURL(blob);
  const img = new Image();
  img.src = imageUrl;
  return img;
}

到这里我们的文件粘贴就完成了,来展示

上传文件

上传文件又包含两部分:图片和普通文件

我们需要在这里做一个判断,如果是图片需要将其内容渲染出来,文件用卡片显示。

html 复制代码
<div class="controls">
    <img src="./assets/emoji.png" alt="表情" title="表情" />
    <img
      src="./assets/file.png"
      alt="发送文件"
      title="发送文件"
      class="file"
    />
</div>
js 复制代码
const fileDom = document.querySelector(".file");

fileDom.addEventListener("click", () => {
  const input = document.createElement("input");
  input.setAttribute("type", "file");
  input.setAttribute("multiple", "true");
  input.addEventListener("change", () => {
    const files = input.files;
    for (let i = 0; i < files.length; i++) {
      const file = files[i];
      fileTypeCheck(file);
    }
  });
  input.click();
});

以上代码,在输入框上方添加了一个文件的icon , 给这个icon 添加点击事件,选择文件并展示。

到这里文件上传就做好了,但是还是存在问题的。

输入框的光标,在鼠标点击页面其他地方的时候会从输入框移出,如果在这时选择文件不会添加到输入框中。

所以,我们在点击文件上传的时候需要叫光标聚焦到输入框。

diff 复制代码
const fileDom = document.querySelector(".file");

fileDom.addEventListener("click", () => {
+  // 光标聚焦到输入框
+  editor.focus();
  const input = document.createElement("input");
  input.setAttribute("type", "file");
  input.setAttribute("multiple", "true");
  input.addEventListener("change", () => {
    const files = input.files;
    for (let i = 0; i < files.length; i++) {
      const file = files[i];
      fileTypeCheck(file);
    }
  });
  input.click();
});

到这里文件上传就完美了。

总结

以上的源码已经上传到了Github, 想要源码的小伙伴自己去拉。代码读取文件那部分可以使用Promise 进行优化。最后,欢迎大佬批评指正。

相关推荐
2分钟速写快排1 分钟前
什么是 RAG?如何用 RAG 实现一个用户记忆?
前端·后端·ai编程
passerby606134 分钟前
如何自己造一个时间处理库
前端·javascript·github
走到天涯海角2 小时前
react里面的长列表渲染优化
前端·react.js·前端框架
小羊没烦恼!2 小时前
Hello Web API系列教程——Web API与国际化
java·服务器·前端·javascript·php
北岛贰2 小时前
迷茫焦虑期,我做了一个带支付带官网的 AI 聊天虚拟恋人 App
前端·人工智能·后端
mayaairi3 小时前
Vue2 组件通讯(三):全局事件总线、PubSub、插槽与组件实例属性
前端·javascript·vue.js
kyriewen4 小时前
面试官问我:AI 都能写代码了,前端凭什么还值 25K
前端·javascript·人工智能
风骏时光牛马5 小时前
AI源码分析:拆解模型底层实现逻辑
前端
BillKu5 小时前
全局样式变量:CSS 自定义属性(--border-color)和SCSS 变量($border-color)的说明
javascript·css·scss
IT_陈寒5 小时前
React子组件莫名其妙重渲染?你可能漏了这个Hook
前端·人工智能·后端