如何实现一个微信PC端富文本输入框?
微信PC端输入框支持图片、文件、文字、Emoji四种消息类型,本篇文章就这四种类型的消息输入进行实现。我们选用HTML5新增标签属性contenteditable
来实现。
contenteditable
属性
contenteditable
是一个全局属性,表示当前元素可编辑。
该属性拥有一下三个属性值:
- true或空字符串: 表示元素可编辑
- false: 表示元素不可编辑
- plaintext-only: 表示只有原始文本可编辑,禁用富文本编辑
给div元素添加contenteditable
, 将元素变为可编辑的状态
<div contenteditable></div>
文字输入
当鼠标聚焦在输入框内时,会有outline
展示,所以要去掉该样式
<!-- index.html -->
<div contenteditable class="editor"></div>
.editor:focus {
outline:none;
}
此时文本就可以正常输入了
图片输入
图片输入分为两部分
- 截图粘贴
- 图片文件复制粘贴
有的浏览器是直接支持截图图片的粘贴的,不过图片文件的复制粘贴就不是我们想要的效果(会把图片粘贴成一个文件的图标俺不是展示图片)。
我们这里要自己做一个图片的粘贴展示,以兼容所有的浏览器。
这里我们使用浏览器提供的粘贴事件paste
来实现
paste
事件函数中存在e.clipboardData.files
属性,该属性是一个数组,当该数组大于0时,就证明用户粘贴的是文件;通过判断 file
的 type
属性是否为image/...
来确定粘贴的内容是否为图片。
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
.image {
max-width: 150px;
max-height: 150px;
}
现在图片的粘贴展示部分就是解释正常的了
细心的同学注意到了,粘贴图片后光标不会向后移动,这是因为我们禁用了粘贴的默认事件,这里需要我们手动处理光标;因为我们使用了
appendChild
将图片插入到了输入框的最后面,就会出现无论光标在哪里,粘贴的图片都是出现在输入框的最后面,我们希望的是在光标所在的地方粘贴内容,接下来我们处理一下光标。
光标处理
处理光标主要以来两个WebAPI
, Selection
和 Range
- Selection:
Selection
对象表示用户选择的文本范围或插入符号的当前位置。它代表页面中的文本选区,可能横跨多个元素。通过window.getSelection()
获取Selection
对象。 具体可查阅MDN-Selection - Range:
Range
接口表示一个包含节点与文本节点的一部分的文档片段。可以使用Document.createRange
方法创建 Range。也可以用Selection
对象的getRangeAt()
方法或者Document
对象的caretRangeFromPoint()
方法获取 Range 对象。具体可查阅MDN-Range
我们使用window.getSelection
获取当前位置,通过 getRangeAt()
获取当前选中的范围,将选中的内容删除,再把我们自己的内容插入到当前的 Range
中,就实现了文件的输入。在这时插入的文件是选中状态的,所以要将选择范围折叠到结束位置,即在粘贴文本的后面显示光标。
/**
* 将指定节点插入到光标位置
* @param {DOM} fileDom dom节点
*/
function insertNode(dom) {
// 获取光标
const selection = window.getSelection();
// 获取选中的内容
const range = selection.getRangeAt(0);
// 删除选中的内容
range.deleteContents();
// 将节点插入范围最前面添加节点
range.insertNode(dom);
// 将光标移到选中范围的最后面
selection.collapseToEnd();
}
/**
* 处理粘贴图片
* @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结构。
/**
* 返回文件卡片的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;
}
.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加入到光标位置
/**
* 处理粘贴文件
* @param {File} file 文件
*/
function handleFile(file) {
insertNode(createFileDom(file));
}
在页面上就可以看到效果啦
可以看到以上的卡片还是存在问题的,
- 光标位置不在卡片的最后面
Backspace
不会将整个卡片都删除掉
卡片的删除
因为我们的卡片是DOM,其父元素也就是输入框,开启了contenteditable
,因此它的该属性也继承了父元素的值,所以本身也是可以编辑的。分析到这里,就会涌现出一个方案——将卡片最外层 div
的contenteditable
属性值设为 false
。
但是,设置了 contenteditable="false"
后就变成了不可编辑元素,光标在他周围就不显示了🥹🥹🥹
再接着考虑,思绪一下又回到了图片身上,我们能不能做一个一样的图片呢?答案是肯定的。
说到做图片我们又会有两种方案:
- svg
- canvas
这里就考虑使用SVG制作卡片。
SVG在这里就不多介绍了,直接开始
<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封装,在上传文件的时候调用生成图片就好了。
封装如下:
/**
* 返回文件卡片的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;
}
到这里我们的文件粘贴就完成了,来展示
上传文件
上传文件又包含两部分:图片和普通文件
我们需要在这里做一个判断,如果是图片需要将其内容渲染出来,文件用卡片显示。
<div class="controls">
<img src="./assets/emoji.png" alt="表情" title="表情" />
<img
src="./assets/file.png"
alt="发送文件"
title="发送文件"
class="file"
/>
</div>
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
添加点击事件,选择文件并展示。
到这里文件上传就做好了,但是还是存在问题的。
输入框的光标,在鼠标点击页面其他地方的时候会从输入框移出,如果在这时选择文件不会添加到输入框中。
所以,我们在点击文件上传的时候需要叫光标聚焦到输入框。
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
进行优化。最后,欢迎大佬批评指正。
来源:juejin.cn/post/7312848658718375971