feat(编辑器): 实现富文本编辑功能并优化交互体验
添加富文本编辑功能,包括插入图片、表情和标签 优化键盘事件处理,支持特殊文本块操作 增加编辑器空状态提示和样式优化 修复光标定位和滚动行为问题
This commit is contained in:
@@ -49,6 +49,7 @@ editor {
|
||||
min-height: 100vh;
|
||||
background-color: #f5f5f5;
|
||||
padding-bottom: 3.2rem;
|
||||
padding-bottom: 8.6667rem;
|
||||
padding-top: 0.4rem;
|
||||
}
|
||||
.container .title-box {
|
||||
@@ -66,6 +67,11 @@ editor {
|
||||
line-height: 0.56rem;
|
||||
outline: none;
|
||||
border: none;
|
||||
resize: none;
|
||||
font-size: 0.36rem;
|
||||
}
|
||||
.container .title-box .input::placeholder {
|
||||
color: #757575;
|
||||
}
|
||||
.container .editor-box {
|
||||
width: 9.4rem;
|
||||
@@ -81,18 +87,47 @@ editor {
|
||||
border: none;
|
||||
outline: none;
|
||||
overflow: auto;
|
||||
font-size: 0.36rem;
|
||||
color: #555555;
|
||||
line-height: 0.56rem;
|
||||
white-space: break-spaces;
|
||||
position: relative;
|
||||
}
|
||||
.container .editor-box .editor.empty:before {
|
||||
content: "输入正文";
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
color: #757575;
|
||||
}
|
||||
.container .editor-box .editor h2 {
|
||||
font-size: 0.48rem;
|
||||
color: #000000;
|
||||
line-height: normal;
|
||||
}
|
||||
.container .editor-box .editor img {
|
||||
width: 8.6rem;
|
||||
width: 90%;
|
||||
display: block;
|
||||
margin: auto;
|
||||
}
|
||||
.container .editor-box .editor .blue {
|
||||
color: #026277;
|
||||
margin: 0 0.1rem;
|
||||
}
|
||||
.container .editor-box .editor .cursor {
|
||||
background-color: red;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
display: inline-block;
|
||||
}
|
||||
.container .editor-box .label {
|
||||
white-space: nowrap;
|
||||
width: 100vw;
|
||||
height: 0.64rem;
|
||||
margin-bottom: 0.4rem;
|
||||
/* height: 0.84rem; */
|
||||
margin-bottom: 0.2rem;
|
||||
width: 9.4rem;
|
||||
overflow: auto;
|
||||
padding-bottom: 0.2rem;
|
||||
}
|
||||
.container .editor-box .label .item {
|
||||
width: fit-content;
|
||||
@@ -129,6 +164,7 @@ editor {
|
||||
color: #555555;
|
||||
padding: 0 0.24rem;
|
||||
margin-right: 0.2rem;
|
||||
position: relative;
|
||||
}
|
||||
.container .editor-box .btn-list .item.pitch {
|
||||
background-color: #f6f6bd;
|
||||
@@ -138,6 +174,26 @@ editor {
|
||||
height: 0.4rem;
|
||||
margin-right: 0.14rem;
|
||||
}
|
||||
.container .editor-box .btn-list .item .file {
|
||||
opacity: 0;
|
||||
/* 隐藏输入框 */
|
||||
background: transparent;
|
||||
border: none;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
.container .editor-box .btn-list .item .file::after {
|
||||
content: "";
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
.container .editor-box .btn-list .unfold {
|
||||
width: 0.72rem;
|
||||
height: 0.72rem;
|
||||
|
||||
@@ -58,6 +58,8 @@ editor {
|
||||
min-height: 100vh;
|
||||
background-color: rgba(245, 245, 245, 1);
|
||||
padding-bottom: 3.2rem;
|
||||
padding-bottom: 8.6667rem;
|
||||
|
||||
padding-top: 0.4rem;
|
||||
|
||||
.title-box {
|
||||
@@ -75,6 +77,11 @@ editor {
|
||||
line-height: 0.56rem;
|
||||
outline: none;
|
||||
border: none;
|
||||
resize: none;
|
||||
font-size: 0.36rem;
|
||||
&::placeholder {
|
||||
color: rgba(117, 117, 117, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,20 +99,60 @@ editor {
|
||||
border: none;
|
||||
outline: none;
|
||||
overflow: auto;
|
||||
font-size: 0.36rem;
|
||||
color: #555555;
|
||||
line-height: 0.56rem;
|
||||
white-space: break-spaces;
|
||||
position: relative;
|
||||
|
||||
&.empty:before {
|
||||
content: "输入正文";
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
color: rgba(117, 117, 117, 1);
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 0.48rem;
|
||||
color: #000000;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
img {
|
||||
width: 8.6rem;
|
||||
width: 90%;
|
||||
display: block;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.fill {
|
||||
// min-width: 2px;
|
||||
// height: 0.56rem;
|
||||
// display: inline-block;
|
||||
}
|
||||
|
||||
.blue {
|
||||
color: #026277;
|
||||
margin: 0 0.1rem;
|
||||
// display: inline-block;
|
||||
}
|
||||
|
||||
.cursor {
|
||||
background-color: red;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
white-space: nowrap;
|
||||
width: 100vw;
|
||||
height: 0.64rem;
|
||||
margin-bottom: 0.4rem;
|
||||
/* height: 0.84rem; */
|
||||
margin-bottom: 0.2rem;
|
||||
width: 9.4rem;
|
||||
overflow: auto;
|
||||
padding-bottom: 0.2rem;
|
||||
|
||||
.item {
|
||||
width: fit-content;
|
||||
@@ -147,6 +194,7 @@ editor {
|
||||
color: #555555;
|
||||
padding: 0 0.24rem;
|
||||
margin-right: 0.2rem;
|
||||
position: relative;
|
||||
|
||||
&.pitch {
|
||||
background-color: rgba(246, 246, 189, 1);
|
||||
@@ -157,6 +205,27 @@ editor {
|
||||
height: 0.4rem;
|
||||
margin-right: 0.14rem;
|
||||
}
|
||||
|
||||
.file {
|
||||
opacity: 0; /* 隐藏输入框 */
|
||||
background: transparent;
|
||||
border: none;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: pointer;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.unfold {
|
||||
|
||||
43
index.html
43
index.html
@@ -3,20 +3,25 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<title>发布帖子</title>
|
||||
<link rel="stylesheet" href="./css/index.css" />
|
||||
<script src="./js/vue.global.js"></script>
|
||||
|
||||
<!-- <script src="https://unpkg.com/vconsole@latest/dist/vconsole.min.js"></script> -->
|
||||
</head>
|
||||
<body>
|
||||
<div class="container" id="appIndex">
|
||||
<div class="title-box">
|
||||
<textarea class="input" placeholder="输入标题(非必填)" :maxlength="titleLength" v-model="title" ref="titleTextarea" @input="adjustTextareaHeight"></textarea>
|
||||
<textarea class="input" id="title" placeholder="输入标题(非必填)" :maxlength="titleLength" v-model="title" ref="titleTextarea" @input="adjustTextareaHeight"></textarea>
|
||||
</div>
|
||||
<div class="editor-box">
|
||||
<div class="editor" ref="editorRef" id="editor" placeholder="输入正文" contenteditable="true" @input="onEditorInput"></div>
|
||||
<div class="editor" :class="{ 'empty': isEmpty }" ref="editorRef" id="editor" placeholder="输入正文" contenteditable="true" @input="onEditorInput">
|
||||
森岛帆高收到过电饭锅电饭锅森岛帆高收到过电饭锅电饭锅森岛帆高收到过电饭锅电饭锅森岛帆高收到过电饭锅电饭锅森岛帆高收到过电饭锅电饭锅森岛帆高收到过电饭锅电饭锅森岛帆高收到过电饭锅电饭锅森岛帆高收到过电饭锅电饭锅森岛帆高收到过电饭锅电饭锅森岛帆高收到过电饭锅电饭锅森岛帆高收到过电饭锅电饭锅森岛帆高收到过电饭锅电饭锅森岛帆高收到过电饭锅电饭锅森岛帆高收到过电饭锅电饭锅森岛帆高收到过电饭锅电饭锅森岛帆高收到过电饭锅电饭锅森岛帆高收到过电饭锅电饭锅森岛帆高收到过电饭锅电饭锅森岛帆高收到过电饭锅电饭锅森岛帆高收到过电饭锅电饭锅森岛帆高收到过电饭锅电饭锅森岛帆高收到过电饭锅电饭锅森岛帆高收到过电饭锅电饭锅森岛帆高收到过电饭锅电饭锅森岛帆高收到过电饭锅电饭锅森岛帆高收到过电饭锅电饭锅森岛帆高收到过电饭锅电饭锅森岛帆高收到过电饭锅电饭锅森岛帆高收到过电饭锅电饭锅森岛帆高收到过电饭锅电饭锅森岛帆高收到过电饭锅电饭锅森岛帆高收到过电饭锅电饭锅森岛帆高收到过电饭锅电饭锅森岛帆高收到过电饭锅电饭锅森岛帆高收到过电饭锅电饭锅森岛帆高收到过电饭锅电饭锅森岛帆高收到过电饭锅电饭锅vv
|
||||
</div>
|
||||
|
||||
<div class="label flexflex" scroll-x>
|
||||
<div class="item" v-for="index in 8" :key="index" @click="insertLabel">#推荐标签</div>
|
||||
<div class="item" v-for="index in 8" :key="index" @click="insertLabel(`推荐标签${index}`)">#推荐标签{{index}}</div>
|
||||
</div>
|
||||
|
||||
<div class="btn-list flexacenter">
|
||||
@@ -24,23 +29,24 @@
|
||||
<img class="icon" src="https://app.gter.net/image/miniApp/offer/T-icon.png" />
|
||||
<div class="text">段落标题</div>
|
||||
</div>
|
||||
<div class="item flexacenter" @click="insertImage">
|
||||
<div class="item flexacenter">
|
||||
<img class="icon" src="https://app.gter.net/image/miniApp/offer/img-icon.png" />
|
||||
<div class="text">图片</div>
|
||||
<input class="file" type="file" @change="insertImage" accept=".png, .jpg, .jpeg" />
|
||||
</div>
|
||||
<div class="item flexacenter" bind:tap="openEmoji">
|
||||
<div class="item flexacenter" @click="openEmoji">
|
||||
<img class="icon" src="https://app.gter.net/image/miniApp/offer/smiling-face-round-black.png" />
|
||||
<div class="text">表情</div>
|
||||
</div>
|
||||
<div class="flex1"></div>
|
||||
<div class="unfold flexcenter">
|
||||
<!-- https://app.gter.net/image/miniApp/offer/fold-icon.png -->
|
||||
<!-- <div class="unfold flexcenter">
|
||||
https://app.gter.net/image/miniApp/offer/fold-icon.png
|
||||
<img class="icon" src="https://app.gter.net/image/miniApp/offer/unfold-icon.png" />
|
||||
</div>
|
||||
</div> -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="anonymity flexacenter" bind:tap="cutAnonymity">
|
||||
<div class="anonymity flexacenter" @click.stop="cutAnonymity">
|
||||
<img v-if="info.anonymity == 1" class="icon-pitch" src="https://app.gter.net/image/miniApp/offer/tick-box.svg" />
|
||||
<div v-else class="icon"></div>
|
||||
<div class="text">匿名发布</div>
|
||||
@@ -56,13 +62,20 @@
|
||||
<cover-view class="new-footer-submit flex1 flexcenter" bindtap="authenticationSubmit">发布</cover-view>
|
||||
</cover-view> -->
|
||||
|
||||
<!-- <view class="pop flexflex" wx:if="{{ emojiState }}" bind:tap="closeEmoji">
|
||||
<scroll-view class="emoji-system-list" scroll-y="{{ true }}" show-scrollbar="{{ false }}" enhanced="{{ true }}" style="font-size: {{ isIos ? '60rpx' : '50rpx' }};">
|
||||
<view class="item" wx:for="{{ optionEmoji }}" wx:key="index" catch:tap="selectEmoji" data-item="{{ item }}">{{ item }}</view>
|
||||
<view class="fill"></view>
|
||||
</scroll-view>
|
||||
</view> -->
|
||||
<div class="pop flexflex" v-if="emojiState" @click="closeEmoji">
|
||||
<div class="emoji-system-list" scroll-y="true" show-scrollbar="false" enhanced="true">
|
||||
<div class="item" v-for="item in optionEmoji" :key="item" @click.stop="selectEmoji(item)">{{ item }}</div>
|
||||
<div class="fill"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- <script>
|
||||
var vConsole = new window.VConsole();
|
||||
console.log("Hello world");
|
||||
|
||||
vConsole.destroy();
|
||||
</script> -->
|
||||
|
||||
<script src="./js/fontSize.js"></script>
|
||||
<script src="./js/index.js"></script>
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
|
||||
function setRemUnit() {
|
||||
var docFontSize = (docEl.clientWidth / sizeUI) * remBase;
|
||||
console.log("bodyEl", bodyEl);
|
||||
docEl.style.fontSize = docFontSize + "px";
|
||||
bodyEl.style.fontSize = 16 / docFontSize + "rem";
|
||||
handleRemAdapt();
|
||||
|
||||
394
js/index.js
394
js/index.js
@@ -1,9 +1,12 @@
|
||||
const { createApp, ref, onMounted, nextTick, onUnmounted, computed, watch } = Vue;
|
||||
|
||||
// 聚焦正文时间 图片表情包等操作放在 键盘上
|
||||
|
||||
createApp({
|
||||
setup() {
|
||||
let titleLength = ref(200);
|
||||
|
||||
let title = ref("发");
|
||||
let title = ref("");
|
||||
|
||||
let info = ref({
|
||||
anonymity: 0,
|
||||
@@ -14,10 +17,9 @@ createApp({
|
||||
const adjustTextareaHeight = () => {
|
||||
nextTick(() => {
|
||||
const textarea = titleTextarea.value;
|
||||
if (textarea) {
|
||||
if (!textarea) return;
|
||||
textarea.style.height = "auto";
|
||||
textarea.style.height = textarea.scrollHeight + "px";
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -29,15 +31,174 @@ createApp({
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener("selectionchange", getFocusedNodeName);
|
||||
// 添加键盘事件监听
|
||||
document.addEventListener("keydown", handleDeleteKey);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener("selectionchange", getFocusedNodeName);
|
||||
document.removeEventListener("keydown", handleDeleteKey);
|
||||
});
|
||||
|
||||
// 处理删除键和回车键事件
|
||||
const handleDeleteKey = (e) => {
|
||||
// 处理删除键(Backspace和Delete)
|
||||
if (e.key === "Backspace" || e.key === "Delete") {
|
||||
const selection = window.getSelection();
|
||||
if (!selection.rangeCount) return;
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
let currentNode = range.startContainer;
|
||||
|
||||
// 如果是文本节点,取其父元素
|
||||
if (currentNode.nodeType === Node.TEXT_NODE) {
|
||||
currentNode = currentNode.parentNode;
|
||||
}
|
||||
|
||||
// 检查当前节点或其祖先是否是span.blue元素
|
||||
let blueSpan = null;
|
||||
let tempNode = currentNode;
|
||||
|
||||
while (tempNode && tempNode !== editorRef.value) {
|
||||
if (tempNode.classList && tempNode.classList.contains("blue")) {
|
||||
blueSpan = tempNode;
|
||||
break;
|
||||
}
|
||||
tempNode = tempNode.parentNode;
|
||||
}
|
||||
|
||||
// 如果在span.blue内且按下删除键,阻止默认行为并删除整个span.blue元素
|
||||
if (blueSpan) {
|
||||
e.preventDefault();
|
||||
|
||||
// 删除整个span.blue元素
|
||||
const parentNode = blueSpan.parentNode;
|
||||
const rangeBefore = document.createRange();
|
||||
rangeBefore.setStartBefore(blueSpan);
|
||||
rangeBefore.setEndBefore(blueSpan);
|
||||
|
||||
// 保存删除前的位置,用于删除后设置光标
|
||||
const startOffset = rangeBefore.toString().length;
|
||||
|
||||
// 删除元素
|
||||
parentNode.removeChild(blueSpan);
|
||||
|
||||
// 设置光标到删除位置
|
||||
const newRange = document.createRange();
|
||||
const textNodes = [];
|
||||
let currentTextNode = parentNode.firstChild;
|
||||
let currentLength = 0;
|
||||
|
||||
while (currentTextNode && currentLength < startOffset) {
|
||||
if (currentTextNode.nodeType === Node.TEXT_NODE) {
|
||||
textNodes.push(currentTextNode);
|
||||
currentLength += currentTextNode.nodeValue.length;
|
||||
}
|
||||
currentTextNode = currentTextNode.nextSibling;
|
||||
}
|
||||
|
||||
if (textNodes.length > 0) {
|
||||
const targetNode = textNodes[textNodes.length - 1];
|
||||
const offset = Math.min(startOffset - (currentLength - targetNode.nodeValue.length), targetNode.nodeValue.length);
|
||||
newRange.setStart(targetNode, offset);
|
||||
newRange.setEnd(targetNode, offset);
|
||||
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(newRange);
|
||||
lastSelection = newRange;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 处理回车键
|
||||
else if (e.key === "Enter") {
|
||||
const selection = window.getSelection();
|
||||
if (!selection.rangeCount) return;
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
let currentNode = range.startContainer;
|
||||
|
||||
// 如果是文本节点,取其父元素
|
||||
if (currentNode.nodeType === Node.TEXT_NODE) {
|
||||
currentNode = currentNode.parentNode;
|
||||
}
|
||||
|
||||
// 检查当前节点或其祖先是否是span.blue元素
|
||||
let blueSpan = null;
|
||||
let tempNode = currentNode;
|
||||
|
||||
while (tempNode && tempNode !== editorRef.value) {
|
||||
if (tempNode.classList && tempNode.classList.contains("blue")) {
|
||||
blueSpan = tempNode;
|
||||
break;
|
||||
}
|
||||
tempNode = tempNode.parentNode;
|
||||
}
|
||||
|
||||
// 如果在span.blue内且按下回车键,阻止默认行为并将光标后面的内容提取到新的span.blue中
|
||||
if (blueSpan && range.startContainer.nodeType === Node.TEXT_NODE) {
|
||||
e.preventDefault();
|
||||
|
||||
// 获取当前文本节点和偏移量
|
||||
const textNode = range.startContainer;
|
||||
const offset = range.startOffset;
|
||||
const textContent = textNode.nodeValue;
|
||||
|
||||
// 如果光标在文本末尾,不做任何处理
|
||||
if (offset >= textContent.length) {
|
||||
// 创建新的span.blue元素并插入到当前span后面
|
||||
const newBlueSpan = document.createElement("span");
|
||||
// newBlueSpan.className = "blue";
|
||||
|
||||
// 创建换行符
|
||||
const br = document.createElement("br");
|
||||
blueSpan.parentNode.insertBefore(br, blueSpan.nextSibling);
|
||||
blueSpan.parentNode.insertBefore(newBlueSpan, br.nextSibling);
|
||||
|
||||
// 设置光标到新的span.blue元素内
|
||||
const newRange = document.createRange();
|
||||
newRange.setStart(newBlueSpan, 0);
|
||||
newRange.setEnd(newBlueSpan, 0);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(newRange);
|
||||
lastSelection = newRange;
|
||||
} else {
|
||||
// 分割文本节点
|
||||
const newTextNode = textNode.splitText(offset);
|
||||
|
||||
// 创建新的span.blue元素
|
||||
const newBlueSpan = document.createElement("span");
|
||||
// newBlueSpan.className = "blue";
|
||||
|
||||
// 将分割后的文本节点移动到新的span.blue元素中
|
||||
newBlueSpan.appendChild(newTextNode.cloneNode(true));
|
||||
|
||||
// 删除原分割后的文本节点
|
||||
newTextNode.parentNode.removeChild(newTextNode);
|
||||
|
||||
// 创建换行符
|
||||
const br = document.createElement("br");
|
||||
|
||||
// 插入换行符和新的span.blue元素
|
||||
blueSpan.parentNode.insertBefore(br, blueSpan.nextSibling);
|
||||
blueSpan.parentNode.insertBefore(newBlueSpan, br.nextSibling);
|
||||
|
||||
// 设置光标到新的span.blue元素内的文本开始位置
|
||||
const newRange = document.createRange();
|
||||
newRange.setStart(newBlueSpan.firstChild, 0);
|
||||
newRange.setEnd(newBlueSpan.firstChild, 0);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(newRange);
|
||||
lastSelection = newRange;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 获取当前焦点所在的节点名称(仅在.editor内)
|
||||
const getFocusedNodeName = () => {
|
||||
const selection = window.getSelection();
|
||||
if (!selection.rangeCount) return null;
|
||||
|
||||
lastSelection = selection.getRangeAt(0);
|
||||
|
||||
// 获取焦点所在的节点
|
||||
let focusedNode = selection.focusNode;
|
||||
|
||||
@@ -50,82 +211,229 @@ createApp({
|
||||
const isInEditor = editorRef.value.contains(focusedNode);
|
||||
if (!isInEditor) return null;
|
||||
|
||||
if (focusedNode.tagName?.toLowerCase() == "h2") isPTitle.value = true;
|
||||
else isPTitle.value = false;
|
||||
lastSelection = selection.getRangeAt(0);
|
||||
console.log("更新选区");
|
||||
updatePTitleStatus();
|
||||
};
|
||||
|
||||
const isPTitle = ref(false);
|
||||
|
||||
// 初始化时设置lastSelection为第一个有效位置
|
||||
let lastSelection = null;
|
||||
|
||||
const onEditorInput = () => {
|
||||
onMounted(() => {
|
||||
setTimeout(() => {
|
||||
focusLastNode();
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
const focusLastNode = () => {
|
||||
const editor = document.getElementById("editor");
|
||||
const selection = window.getSelection();
|
||||
|
||||
// 清除现有选择范围
|
||||
selection.removeAllRanges();
|
||||
|
||||
// 创建新的范围对象
|
||||
const range = document.createRange();
|
||||
|
||||
// 找到最后一个有效子节点(跳过空白文本节点)
|
||||
let lastNode = editor.lastChild;
|
||||
while (lastNode) {
|
||||
// 检查是否为有效节点(非空白文本节点)
|
||||
if (!(lastNode.nodeType === 3 && lastNode.textContent.trim() === "")) {
|
||||
break;
|
||||
}
|
||||
lastNode = lastNode.previousSibling;
|
||||
}
|
||||
|
||||
if (lastNode) {
|
||||
// 设置范围到最后一个节点的末尾
|
||||
range.setStartAfter(lastNode);
|
||||
range.setEndAfter(lastNode);
|
||||
} else {
|
||||
// 如果编辑器为空,选择整个编辑器
|
||||
range.selectNodeContents(editor);
|
||||
range.collapse(false);
|
||||
}
|
||||
|
||||
// 将范围添加到选择对象,不设置焦点
|
||||
selection.addRange(range);
|
||||
};
|
||||
|
||||
let isEmpty = ref(true);
|
||||
|
||||
const onEditorInput = (event) => {
|
||||
const selection = window.getSelection();
|
||||
|
||||
if (selection.rangeCount > 0) {
|
||||
lastSelection = selection.getRangeAt(0);
|
||||
console.log("更新选区");
|
||||
updatePTitleStatus();
|
||||
}
|
||||
|
||||
judgeIsEmpty();
|
||||
};
|
||||
|
||||
// 判断是否为空
|
||||
const judgeIsEmpty = () => {
|
||||
const text = editorRef.value.innerText;
|
||||
console.log("text", text);
|
||||
|
||||
isEmpty.value = text.length == 0 && !editorRef.value.querySelector("img");
|
||||
};
|
||||
|
||||
const paragraphTitle = () => {
|
||||
// 保存当前滚动位置
|
||||
const scrollTop = window.scrollY;
|
||||
|
||||
editorRef.value.focus();
|
||||
if (!lastSelection) return;
|
||||
const selection = window.getSelection();
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(lastSelection);
|
||||
const focusNode = lastSelection.startContainer;
|
||||
document.execCommand("formatBlock", false, focusNode?.parentNode?.tagName == "H2" ? "P" : "H2");
|
||||
|
||||
// 使用try-catch确保即使命令执行失败也能恢复滚动位置
|
||||
try {
|
||||
document.execCommand("formatBlock", false, isPTitle.value ? "P" : "H2");
|
||||
} catch (error) {
|
||||
console.error("应用段落格式失败:", error);
|
||||
}
|
||||
|
||||
// 立即恢复滚动位置
|
||||
window.scrollTo(0, scrollTop);
|
||||
|
||||
// 更新状态
|
||||
updatePTitleStatus();
|
||||
};
|
||||
|
||||
const updatePTitleStatus = () => {
|
||||
if (lastSelection) {
|
||||
const node = lastSelection.commonAncestorContainer;
|
||||
let parentElement = node.parentElement;
|
||||
|
||||
isPTitle.value = node.nodeName === "H2" || (node.nodeType === Node.TEXT_NODE && node.parentNode?.nodeName === "H2");
|
||||
|
||||
// 死循环,直到遇到终止条件
|
||||
while (true) {
|
||||
// 如果没有父元素了(到达文档根节点),退出循环返回false
|
||||
if (!parentElement) {
|
||||
isPTitle.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// 遇到id为"editor"的元素,返回false
|
||||
if (parentElement.id === "editor") {
|
||||
isPTitle.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// 遇到nodeName为"H2"的元素,返回true(注意nodeName是大写的)
|
||||
if (parentElement.nodeName === "H2") {
|
||||
isPTitle.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// 继续向上查找父元素
|
||||
parentElement = parentElement.parentElement;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const insertImage = () => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = "image/*";
|
||||
input.onchange = (e) => {
|
||||
const file = e.target.files[0];
|
||||
const maxSize = 20 * 1024 * 1024; // 20MB
|
||||
|
||||
const insertImage = (event) => {
|
||||
const file = event.target.files[0];
|
||||
|
||||
if (!file) return; // 处理未选择文件的情况
|
||||
|
||||
if (file.size > maxSize) {
|
||||
console.log("文件大小不能超过 20MB");
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
// 创建图片元素
|
||||
const imgSrc = e.target.result;
|
||||
console.log("imgSrc", imgSrc);
|
||||
|
||||
try {
|
||||
const range = lastSelection;
|
||||
const img = document.createElement("img");
|
||||
img.src = e.target.result;
|
||||
img.alt = "用户上传图片";
|
||||
img.src = imgSrc;
|
||||
range.insertNode(img);
|
||||
const div = document.createElement("div");
|
||||
range.insertNode(div);
|
||||
|
||||
// 确保编辑器获得焦点
|
||||
editorRef.value.focus();
|
||||
|
||||
// 获取选择对象
|
||||
const selection = window.getSelection();
|
||||
|
||||
// 如果有选择范围,在选择范围插入图片
|
||||
console.log("rangeCount", selection.rangeCount);
|
||||
|
||||
if (selection.rangeCount > 0) {
|
||||
const range = selection.getRangeAt(0);
|
||||
range.deleteContents(); // 清除当前选择内容
|
||||
range.insertNode(img); // 插入图片
|
||||
|
||||
// 将光标移动到图片后面
|
||||
range.setStartAfter(img);
|
||||
range.setEndAfter(img);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
} else {
|
||||
// 如果没有选择范围,直接追加到编辑器末尾
|
||||
editorRef.value.appendChild(img);
|
||||
judgeIsEmpty();
|
||||
} catch (error) {
|
||||
console.error("插入图片出错:", error);
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
input.click();
|
||||
|
||||
const insertLabel = (label) => {
|
||||
const span = document.createElement("span");
|
||||
span.innerHTML = `<span class="blue">#${label}</span> <span class="fill"></span> `;
|
||||
lastSelection.insertNode(span);
|
||||
|
||||
// 移动光标到emoji后面
|
||||
lastSelection.setStartAfter(span);
|
||||
lastSelection.setEndAfter(span);
|
||||
|
||||
judgeIsEmpty();
|
||||
};
|
||||
|
||||
return { editorRef, info, title, titleLength, titleTextarea, adjustTextareaHeight, isPTitle, paragraphTitle, insertImage, onEditorInput };
|
||||
let emojiState = ref(false);
|
||||
|
||||
const optionEmoji = ref(["😀", "😁", "😆", "😅", "😂", "😉", "😍", "🥰", "😘", "🤥", "😪", "😵💫", "🤓", "🥺", "😋", "😜", "🤪", "😎", "🤩", "🥳", "😔", "🙁", "😭", "😡", "😳", "🤗", "🤔", "🤭", "🤫", "😯", "😵", "🙄", "🥴", "🤢", "🤑", "🤠", "👌", "✌️", "🤟", "🤘", "🤙", "👍", "👎", "✊", "👏", "🤝", "🙏", "💪", "❎️", "✳️", "✴️", "❇️", "#️⃣", "*️⃣", "1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣", "6️⃣", "7️⃣", "8️⃣", "9️⃣", "🔟", "🆗", "🈶", "🉐", "🉑", "🌹", "🥀", "🌸", "🌺", "🌷", "🌲", "☘️", "🍀", "🍁", "🌙", "⭐", "🌍", "☀️", "⭐️", "🌟", "☁️", "🌈", "☂️", "❄️", "☃️", "☄️", "🔥", "💧", "🍎", "🍐", "🍊", "🍉", "🍓", "🍑", "🍔", "🍟", "🍕", "🥪", "🍜", "🍡", "🍨", "🍦", "🎂", "🍰", "🍭", "🍿", "🍩", "🧃", "🍹", "🍒", "🥝", "🥒", "🥦", "🥨", "🌭", "🥘", "🍱", "🍢", "🥮", "🍩", "🍪", "🧁", "🍵", "🍶", "🍻", "🥂", "🧋", "🎉", "🎁", "🧧", "🎃", "🎄", "🧨", "✨️", "🎈", "🎊", "🎋", "🎍", "🎀", "🎖️", "🏆️", "🏅", "💌", "📬", "🚗", "🚕", "🚲", "🛵", "🚀", "🚁", "⛵", "🚢", "🔮", "🧸", "🀄️"]);
|
||||
|
||||
const openEmoji = () => (emojiState.value = true);
|
||||
|
||||
const closeEmoji = () => (emojiState.value = false);
|
||||
|
||||
const selectEmoji = (emoji) => {
|
||||
const textNode = document.createTextNode(emoji);
|
||||
lastSelection.insertNode(textNode);
|
||||
|
||||
// 移动光标到emoji后面
|
||||
lastSelection.setStartAfter(textNode);
|
||||
lastSelection.setEndAfter(textNode);
|
||||
|
||||
judgeIsEmpty();
|
||||
getCursorPosition();
|
||||
};
|
||||
|
||||
const getCursorPosition = () => {
|
||||
const range = lastSelection;
|
||||
const tempElement = document.createElement("span");
|
||||
tempElement.classList.add("cursor");
|
||||
// 在光标位置插入临时元素
|
||||
range.insertNode(tempElement);
|
||||
|
||||
// 获取临时元素的位置信息
|
||||
const rect = tempElement.getBoundingClientRect();
|
||||
|
||||
// 可视窗口高度
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
// 计算目标位置:中间偏上(视口高度的30%位置)
|
||||
// 公式:元素顶部相对于视口的位置 + 滚动距离 - 目标位置(视口高度的30%)
|
||||
const targetPosition = window.scrollY + rect.top - viewportHeight * 0.3; // 30%位置,比正中间更靠上
|
||||
|
||||
// 平滑滚动到目标位置
|
||||
window.scrollTo({
|
||||
top: targetPosition,
|
||||
behavior: "smooth", // 平滑滚动,移除则为瞬间滚动
|
||||
});
|
||||
|
||||
// 移除临时元素
|
||||
tempElement.parentNode.removeChild(tempElement);
|
||||
};
|
||||
|
||||
const cutAnonymity = () => (info.value.anonymity = info.value.anonymity ? 0 : 1);
|
||||
|
||||
return { cutAnonymity, isEmpty, selectEmoji, closeEmoji, openEmoji, optionEmoji, emojiState, insertLabel, editorRef, info, title, titleLength, titleTextarea, adjustTextareaHeight, isPTitle, paragraphTitle, insertImage, onEditorInput };
|
||||
},
|
||||
}).mount("#appIndex");
|
||||
|
||||
Reference in New Issue
Block a user