手撸编辑器(二)
发表于:2023-06-19 |

前言

最近更新不太频繁,是因为我最近有点累了,稍微放松了一段日子,接下来我会继续更新文章的,上一篇手撸编辑器的文章,已经简单介绍了如何使用contenteditable属性实现一个简单的编辑器,这一篇文章,我会继续完善这个编辑器,让它更加完善。

复制文件

其实,这次最难的就是文件复制该怎么弄,一开始我的想法是自己搞一个div,然后写一堆样式,再把这个div插入进去,然后我发现我的光标定位会出现问题,因为这个div一样是可编辑的,光标会到这个div的子元素上面去,之后我又想到我可以搞一个canvas,但是我尝试了一下,canvas放进去之后输入框光标不见了,且无法再定位,正当我捉急的时候,到了中午吃饭的饭点,我灵光一现,把文件展示的div的dom转化成图片不就好了吗?说干就干。

图片转dom

大家在网上这个一搜其实一大把,这里我用了一个插件库dom-to-image的库

1
npm install dom-to-image
1
import domtoimage from "dom-to-image";

绘制dom转化成图片

这里插入到app节点是因为dom转图片这个dom必须存在于dom树中,否则无法转化,我给app节点加了overflow:hidden,这样就不会影响到页面的显示了。处理完节点转化成图片之后,我再把这个节点删除,避免没必要的内存泄漏,这里的aov是因为我的视频文件发送类型和普通的excel,word这些发送的类型是不一样,因此我区分开了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// 文件对应
const fileImageMap = ref<any>({});
// 插入文件格式
const insertFile = (img: string, file: any, isAov = false) => {
const fileElement = document.createElement("div") as any;
// // 生成随机数
const random = Math.floor(Math.random() * 0xffffff).toString();
fileElement.id = random;
fileElement.setAttribute(
"style",
"display:flex;flex-direction:column;width:72px;height:100px;align-items:center;justify-content:end;"
);
fileElement.innerHTML = `
<img src=${img} style="width: 46px;height: 52px;margin-bottom:6px"/>
<div style="font-size:12px;width:72px;color:#7795C2;word-break:keep-all;white-space:nowrap;overflow:hidden;text-overflow:ellipsis; text-align:center">
${file.name}
</div>
`;
const appDom = document.querySelector("#app") as any;
appDom.appendChild(fileElement);
domtoimage
.toSvg(document.getElementById(random) as any)
.then(function (dataUrl: string) {
const imgElement = document.createElement("img") as any;
imgElement.src = dataUrl;
imgElement.width = 72;
imgElement.height = 100;
imgElement.style.margin = "0 5px";
imgElement.id = random;
if (isAov) {
imgElement.setAttribute("type", "aov");
}
fileImageMap.value[random] = file;
insertContent(imgElement.outerHTML);
appDom.removeChild(fileElement);
})
.catch(function (error: any) {
ElMessage.error("文件转化失败");
});
};

完善根据光标插入内容逻辑

这一次,我完善了根据光标插入内容的格式,这样就可以根据光标位置插入内容,如果光标未在div中,就在div末尾插入内容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
// 插入内容
const insertContent = (html: string) => {
let sel, range;
if (window.getSelection) {
sel = window.getSelection();
if (sel && sel.rangeCount === 0 && savedRange.value !== null)
sel.addRange(savedRange.value); // 保留光标在文字中间插入的最后位置
if (sel && sel.rangeCount) range = sel.getRangeAt(0);
if (["", null, undefined].includes(range)) {
// 如果div没有光标,则在div内容末尾插入
range = keepCursorEnd(true).getRangeAt(0);
} else {
const contentRange = document.createRange();
contentRange.selectNode(editor.value);
// 对比range,检查光标是否在输入范围内
const compareStart = range.compareBoundaryPoints(
Range.START_TO_START,
contentRange
);
const compareEnd = range.compareBoundaryPoints(
Range.END_TO_END,
contentRange
);
const compare = compareStart !== -1 && compareEnd !== 1;
if (!compare) range = keepCursorEnd(true).getRangeAt(0);
}
let input = range.createContextualFragment(html);
let lastNode = input.lastChild; // 记录插入input之后的最后节点位置
range.insertNode(input);
if (lastNode) {
// 如果有最后的节点
range = range.cloneRange();
range.setStartAfter(lastNode);
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
}
}
};

const keepCursorEnd = (isReturn) => {
if (window.getSelection) {
editor.value.focus();
let sel = window.getSelection(); // 创建range
sel.selectAllChildren(editor.value); // range 选择obj下所有子内容
sel.collapseToEnd(); // 光标移至最后
if (isReturn) return sel;
}
};
// 记录光标位置
onMounted(() => {
// 注册光标移动事件
document.addEventListener("selectionchange", handleSelectionChange, false);
});
const savedRange = ref<any>(null);
// 保留当前选择的范围
const handleSelectionChange = () => {
let sel = window.getSelection && window.getSelection();
if (sel && sel.rangeCount) {
savedRange.value = sel.getRangeAt(0);
}
};

文件上传加载进度条

同样的,这次我给上传文件加上了进度条,用了axios的一个onUploadProgress属性,这里创建了一个msgTag的原因是因为,为了消息流畅,我需要先将消息显示在页面上,然后去发送消息,然而,这样就会不可避免造成消息重复,虽然我加了根据消息id去重的逻辑,但是我们自己推上去页面的消息是没有id的,因此,我让后端加了这个字段,并且在请求成功之后把消息id返回,这样的话我就可以根据msgTag把这个返回的消息id赋值给这条消息,这样的上拉滚动加载下一页的时候就不会出现消息重复的情况了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
// 批量上传媒体信息
const handleUploadMuch = (
param: { type: number; file: any; accept: string; url: string }[]
) => {
const formData = new FormData();
const paramList: any = [];
param.forEach((item) => {
// 生成随机数
const random = Math.floor(Math.random() * 0xffffff).toString();
formData.append("files", item.file);
const param = {
msgContent: item.file.name,
msgType: item.type + 1,
roomId: store.curRow.id,
sendOpenId: store.openId,
attachment: item.type !== 1 ? item.url : item.file.name,
isError: false,
source: "web",
msgTag: random,
};
paramList.push(param);
changeMsgAndShowList([
{
...param,
createTime: formatDate(Date.now())[6],
} as msgItem,
...msgList.value,
]);
nextTick(() => {
currentPage.value = 1;
sourceReady(true, [param as msgItem]);
});
});
formData.append("roomId", store.curRow.id);
axios
.post(`${baseURL}chat/file/files`, formData, {
onUploadProgress(e: any) {
// 进度条显示
curFileProgressShow.value = true;
// 进度条内容
curFileProgress.value = Math.round((e.loaded * 100) / e.total);
},
})
.then((res) => {
const list = res.data.data;
curFileProgressShow.value = false;
curFileProgress.value = 0;
if (list && list.length > 0) {
list.forEach((item: string, index: number) => {
// url替换掉
const needReplaceIndex = msgList.value.findIndex(
(citem) => citem.msgTag === paramList[index].msgTag
);
msgList.value[needReplaceIndex].attachment = item;
if (item.url) {
window.URL.revokeObjectURL(url);
}
handleSend("", 0, "", { ...paramList[index], attachment: item });
});
}
})
.catch((error) => {
handleError(error);
});
};

消息发送

这一块我就不过多阐述了,和之前发送图片是一样的。

效果图

上一篇:
手撸编辑器(三)
下一篇:
iframe内嵌网页keep-alive不生效以及手动刷新iframe页面