refactor(editor): 优化富文本编辑器内容格式转换逻辑

- 改进 HTML 到 Markdown 的转换规则,处理块级元素和行内元素的嵌套关系
- 优化图片和视频标签的解析逻辑,确保附件正确提取
- 修复换行符处理问题,避免产生多余空行
- 增强居中文本和标题的格式转换准确性
- 清理调试日志和冗余代码
This commit is contained in:
A1300399510
2025-11-30 23:54:41 +08:00
parent 1b5cf79300
commit 57bf0ed3eb
5 changed files with 182 additions and 80 deletions

View File

@@ -160,14 +160,21 @@
line-height: 26px;
margin-bottom: 66px;
}
#details .matter .matter-left .html * {
background: transparent !important;
color: #555555 !important;
}
#details .matter .matter-left .html a {
text-decoration: underline;
color: #04b0d5;
color: #04b0d5 !important;
}
#details .matter .matter-left .html a * {
color: #04b0d5 !important;
}
#details .matter .matter-left .html .blue {
font-size: 15px;
line-height: 26px;
color: #026277;
color: #026277 !important;
margin: 0 4px;
text-decoration: none;
}
@@ -176,19 +183,17 @@
display: inline-block;
}
#details .matter .matter-left .html video {
margin: 0 auto;
margin: 0 auto 5px;
height: 300px;
display: block;
}
#details .matter .matter-left .html h1 {
font-family: "PingFangSC-Semibold", "PingFang SC Semibold", "PingFang SC", sans-serif;
font-weight: 650;
color: #000000;
color: #000000 !important;
font-size: 18px;
line-height: 30px;
}
#details .matter .matter-left .html tr,
#details .matter .matter-left .html td {
background: transparent;
}
#details .matter .matter-left .last-time {
color: #aaaaaa;
font-size: 13px;

View File

@@ -36,6 +36,7 @@
.content {
flex-direction: column;
.name {
font-size: 14px;
color: #333333;
@@ -55,6 +56,7 @@
.operate {
position: relative;
.view {
.icon {
width: 13px;
@@ -185,15 +187,25 @@
line-height: 26px;
margin-bottom: 66px;
* {
background: transparent !important;
color: #555555 !important;
}
a {
text-decoration: underline;
color: #04b0d5;
color: #04b0d5 !important;
* {
color: #04b0d5 !important;
}
}
.blue {
font-size: 15px;
line-height: 26px;
color: #026277;
color: #026277 !important;
margin: 0 4px;
text-decoration: none;
}
@@ -204,21 +216,20 @@
}
video {
margin: 0 auto;
margin: 0 auto 5px;
height: 300px;
display: block;
}
h1 {
font-family: "PingFangSC-Semibold", "PingFang SC Semibold", "PingFang SC", sans-serif;
font-weight: 650;
color: #000000;
color: #000000 !important;
font-size: 18px;
line-height: 30px;
}
tr,
td {
background: transparent;
}
}
.last-time {
@@ -244,6 +255,7 @@
height: 20px;
margin-right: 6px;
}
font-size: 14px;
color: #333333;
cursor: pointer;
@@ -254,6 +266,7 @@
&.share {
position: relative;
&:hover {
.share-box {
display: flex;
@@ -353,6 +366,7 @@
padding-top: 20px;
padding-bottom: 12px;
border-bottom: 1px solid #ebebeb;
.text {
font-family: "PingFangSC-Semibold", "PingFang SC Semibold", "PingFang SC", sans-serif;
font-weight: 650;
@@ -494,6 +508,7 @@
.author-info {
color: #7f7f7f;
font-size: 13px;
.amount {
font-family: "PingFangSC-Semibold", "PingFang SC Semibold", "PingFang SC", sans-serif;
font-weight: 650;
@@ -508,6 +523,7 @@
width: calc(100% - 16px);
padding-bottom: 22px;
margin-left: 16px;
.medal-title {
font-size: 14px;
color: #7f7f7f;
@@ -551,6 +567,7 @@
&:hover {
color: #000000;
}
.dot {
width: 6px;
height: 6px;
@@ -608,6 +625,7 @@
font-weight: 400;
color: #7f7f7f;
font-size: 14px;
.sum {
font-family: "PingFangSC-Semibold", "PingFang SC Semibold", "PingFang SC", sans-serif;
font-weight: 650;
@@ -682,6 +700,7 @@
.coins-total {
color: #7f7f7f;
font-size: 14px;
.sum {
font-family: "PingFangSC-Semibold", "PingFang SC Semibold", "PingFang SC", sans-serif;
font-weight: 650;
@@ -713,6 +732,7 @@
.user {
color: #555555;
font-size: 13px;
.avatar {
width: 32px;
height: 32px;
@@ -724,6 +744,7 @@
.amount {
color: #000000;
font-size: 16px;
.text {
font-size: 13px;
margin-left: 2px;
@@ -883,8 +904,7 @@
cursor: pointer;
}
.answer-discuss .input-box .bottom .operate .item {
}
.answer-discuss .input-box .bottom .operate .item {}
.answer-discuss .input-box .bottom .operate .item .emoji-box {
width: 582px;
@@ -1421,29 +1441,35 @@
background-color: rgba(0, 0, 0, 0.5);
z-index: 12;
}
.answer-discuss .edit-comment .box {
width: 650px;
border-radius: 10px;
background: #fff;
padding: 20px 15px;
}
.answer-discuss .edit-comment .box .text {
font-size: 18px;
font-weight: 650;
margin-bottom: 15px;
color: #000;
}
.answer-discuss .edit-comment .box .input-box {
margin-right: 0;
padding-top: 10px;
}
.answer-discuss .edit-comment .box .input-box .bottom {
// border-top: 1px solid #ebebeb;
}
.answer-discuss .edit-comment .box .btn-list {
padding: 15px 0;
justify-content: flex-end;
}
.answer-discuss .edit-comment .box .btn-list .btn {
font-size: 14px;
color: #333;
@@ -1457,6 +1483,7 @@
border: 1px solid #ebebeb;
margin-left: 20px;
}
.answer-discuss .edit-comment .box .btn-list .btn.send {
background-color: #fddf6d;
border: 1px solid #fddf6d;
@@ -1469,6 +1496,7 @@
width: 100vw;
height: 100vh;
}
.answer-discuss .emoji-box-mask {
position: fixed;
top: 0;
@@ -1483,6 +1511,7 @@
@media screen and (max-width: 850px) {
#details {
padding: 10px 10px 0;
.head-top {
display: none;
}
@@ -1494,6 +1523,7 @@
.matter {
.matter-left {
margin-right: 0 !important;
.action-bar {
margin-right: 0 !important;
justify-content: space-around;
@@ -1508,10 +1538,13 @@
.related-head {
padding-left: 14px;
}
.list {
padding: 14px;
.item {
width: 100% !important;
&:not(:last-child) {
margin-bottom: 7px;
}
@@ -1523,10 +1556,12 @@
}
}
}
.sidebar-box {
display: none;
}
}
.answer-discuss {
padding: 15px;
@@ -1594,6 +1629,7 @@
.comments-item {
.comments-header {
font-size: 12px;
.comments-title {
height: 14px !important;
}
@@ -1610,6 +1646,7 @@
.comments-box {
.comments-item {
.comments-header {
.comment-icon,
.like-box {
margin-left: 15px;
@@ -1619,4 +1656,4 @@
}
}
}
}
}

View File

@@ -398,12 +398,16 @@
}
#appIndex .header-content-box .header-content-right .offer-box {
width: 240px;
height: 214px;
background-color: #fff;
border: 1px solid #e9eef2;
border-radius: 10px;
padding: 17px 10px;
overflow: hidden;
height: 64px;
padding: 15px 10px;
}
#appIndex .header-content-box .header-content-right .offer-box.big {
height: 214px;
padding: 17px 10px;
}
#appIndex .header-content-box .header-content-right .offer-box.small {
height: 64px;

View File

@@ -18,9 +18,11 @@
&:not(:last-child) {
margin-right: 12px;
}
a {
display: block;
}
img {
width: 468px;
height: 60px;
@@ -37,6 +39,7 @@
border-radius: 20px 20px 20px 0;
margin-top: 0;
margin-bottom: 10px;
.icon {
width: 15px;
height: 12px;
@@ -91,6 +94,7 @@
.people {
position: relative;
justify-content: space-between;
&::after {
content: "";
position: absolute;
@@ -119,6 +123,7 @@
width: 26px;
height: 26px;
border-radius: 50%;
.img {
width: 26px;
height: 26px;
@@ -128,12 +133,15 @@
&:nth-child(4) {
margin-right: -9px;
}
&:nth-child(3) {
margin-right: -9px;
}
&:nth-child(2) {
margin-right: -7px;
}
&:nth-child(2) {
margin-right: -5px;
}
@@ -145,6 +153,7 @@
.topic-list {
.item {
cursor: pointer;
&:not(:last-child) {
margin-bottom: 1px;
}
@@ -222,8 +231,10 @@
border-radius: 10px;
padding-left: 12px;
margin-top: 12px;
.adv {
margin-right: 26px;
.adv-icon {
width: 295px;
height: 118px;
@@ -279,6 +290,7 @@
.header-content-right {
width: 240px;
.post-entrance {
background-color: #fff;
border: 1px solid #e9eef2;
@@ -357,9 +369,11 @@
height: 140px;
margin-bottom: 10px;
display: block;
a {
display: block;
}
img {
width: 240px;
height: 140px;
@@ -394,12 +408,15 @@
&:nth-child(1) {
border-top-left-radius: 8px;
}
&:nth-child(2) {
border-top-right-radius: 8px;
}
&:nth-child(3) {
border-bottom-left-radius: 8px;
}
&:nth-child(4) {
border-bottom-right-radius: 8px;
}
@@ -429,6 +446,7 @@
line-height: 22px;
position: relative;
.title {
font-family: "PingFangSC-Semibold", "PingFang SC Semibold", "PingFang SC", sans-serif;
font-weight: 650;
@@ -455,6 +473,7 @@
padding: 20px;
box-shadow: 0px 0px 5px rgba(0, 0, 0, 0.168627450980392);
flex-direction: column;
.QRcode {
width: 100px;
height: 100px;
@@ -472,13 +491,21 @@
.offer-box {
width: 240px;
height: 214px;
// height: 214px;
background-color: #fff;
border: 1px solid #e9eef2;
border-radius: 10px;
padding: 17px 10px;
// padding: 17px 10px;
overflow: hidden;
height: 64px;
padding: 15px 10px;
&.big {
height: 214px;
padding: 17px 10px;
}
&.small {
height: 64px;
padding: 15px 10px;
@@ -521,6 +548,7 @@
.admission {
margin-bottom: 30px;
.admission-header {
margin-bottom: 24px;
display: inline-flex;
@@ -543,6 +571,7 @@
.admission-list {
flex-wrap: wrap;
.admission-item {
width: 291px;
height: 103px;
@@ -602,6 +631,7 @@
&:not(:last-child) {
margin-bottom: 10px;
}
.item {
font-family: "PingFangSC-Regular", "PingFang SC", sans-serif;
font-weight: 400;
@@ -622,6 +652,7 @@
}
}
}
.item-box {
margin-bottom: 12px;
}
@@ -641,6 +672,7 @@
height: 220px;
margin-bottom: 12px;
cursor: pointer;
img {
width: 291px;
height: 220px;
@@ -669,4 +701,4 @@
}
}
}
}
}

View File

@@ -53,8 +53,6 @@ const editApp = createApp({
cUpload();
// console.log(valueA.value);
valueUrl = valueA.value.innerText;
if (location.hostname == "127.0.0.1") {
@@ -67,6 +65,7 @@ const editApp = createApp({
isLogin.value = true;
}
});
let imageLength = 10;
@@ -167,19 +166,13 @@ const editApp = createApp({
const infoTarget = data.info || {};
// if (location.hostname == "127.0.0.1")
// infoTarget.content = `<p><img src="https://oss.x-php.com/Zvt57TuJSUvkyhw-xG_Y2l-U_polfHeD1NFX9ddrB_WbUGy8P79gQxccQOeR45kV7NkzNDQyOQ~~?aid=1009985" alt="图片描述" data-href="https://i-operation.csdnimg.cn/ad/ad_pic/a0beaaca1e2047e0ae5c0783e02b3c0a.png" style=""/></p><div data-w-e-type="video" data-w-e-is-void>
// <video poster="https://o.x-php.com/Zvt57TuJSUvkyhw-xG_Y2l-U_polcniH1NFX9ddrB_WbUGy8P79gQxcSQbvLtMsV7NkzNDQyOQ~~?aid=1009771" controls="true" width="auto" height="auto"><source src="https://o.x-php.com/Zvt57TuJSUvkyhw-xG_Y2l-U_polcniG1NFX9ddrB_WbUGy8P79gQxcSFbqQ78MV7NkzNDQyOQ~~?aid=1009770" type="video/mp4"/></video>
// </div><p><br></p>`;
console.log("content", infoTarget.content);
// console.log("content", infoTarget.content);
if (infoTarget.content) infoTarget.content = restoreHtml(infoTarget.content, infoTarget.attachments);
console.log("content", infoTarget.content);
// console.log("content", infoTarget.content);
info.value = infoTarget;
token.value = data.token;
console.log("data", data);
initEditor();
})
@@ -216,18 +209,9 @@ const editApp = createApp({
["insertImage"]: {
onInsertedImage(imageNode) {
console.log("insertImage");
// if (imageNode == null) return;
const { src, alt, url, href } = imageNode;
console.log("src", src);
console.log("alt", alt);
console.log("url", url);
console.log("href", href);
},
async parseImageSrc(src) {
console.log("parseImageSrc", src);
// 如果图片链接中已经包含了 ?aid= ,则说明是本站图片,直接返回,无需处理
if (src.includes("?aid=")) return src;
@@ -323,6 +307,7 @@ const editApp = createApp({
editor = createEditor({
selector: "#editor-container",
html: infoTarget.content,
// html: "",
config: editorConfig,
mode: "default",
});
@@ -409,10 +394,10 @@ const editApp = createApp({
let html = formattedText || "";
// 0.5 [p] -> <p>
html = html.replace(/\[p\]([\s\S]*?)\[\/p\]/gi, "<p>$1</p>");
// html = html.replace(/\[p\]([\s\S]*?)\[\/p\]/gi, "<p>$1</p>");
// 0. 基础清理:换行符转 <br>div 转 p (保持原有逻辑)
html = html.replace(/\n/g, "<br>");
html = html.replace(/(\r\n|\n|\r)/g, "<br>");
html = html.replace(/<div(\s|>)/gi, "<p$1");
html = html.replace(/<\/div>/g, "</p>");
html = html.replace(/<br><div>/g, "<div>");
@@ -432,11 +417,15 @@ const editApp = createApp({
// 普通居中
html = html.replace(/\[align=center\]([\s\S]*?)\[\/align\]/gi, '<p style="text-align: center;">$1</p>');
// 移除 align 后紧跟的换行符(因为 align 转换为了块级元素 p/h1其后的换行符通常是多余的
html = html.replace(/(<p style="text-align: center;">.*?<\/p>)\s*<br>/gi, "$1");
html = html.replace(/(<h1 style="text-align: center;">.*?<\/h1>)\s*<br>/gi, "$1");
// 定义图片处理函数
const processImg = (aid, width, height, href) => {
const image = imageList.find((img) => String(img.aid) === String(aid));
if (!image) return "";
if (!image) return "";
// 移除已使用的图片
const index = imageList.findIndex((img) => String(img.aid) === String(aid));
if (index > -1) imageList.splice(index, 1);
@@ -451,11 +440,11 @@ const editApp = createApp({
const wStyle = formatStyleVal(width);
const hStyle = formatStyleVal(height);
let styleParts = [];
if (wStyle) styleParts.push(`width: ${wStyle}`);
if (hStyle) styleParts.push(`height: ${hStyle}`);
if (styleParts.length > 0) style = `style="${styleParts.join('; ')};"`;
let dataHref = href ? ` data-href="${href}"` : "";
@@ -519,29 +508,62 @@ const editApp = createApp({
// 确保所有顶层元素都是块级元素 (Slate/WangEditor 要求)
const blockTags = ['P', 'DIV', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'BLOCKQUOTE', 'UL', 'OL', 'PRE', 'TABLE', 'FIGURE', 'HR'];
const children = Array.from(__c.childNodes);
let wrapper = null;
const newContainer = document.createElement('div');
let currentInlineNodes = [];
let lastWasBlock = false;
children.forEach((node) => {
const flushInlines = () => {
if (currentInlineNodes.length > 0) {
if (currentInlineNodes.length > 0) {
const p = document.createElement('p');
currentInlineNodes.forEach(node => p.appendChild(node));
newContainer.appendChild(p);
}
currentInlineNodes = [];
}
};
Array.from(__c.childNodes).forEach((node) => {
// 判断是否为块级元素
const isBlock = node.nodeType === 1 && blockTags.includes(node.tagName);
if (isBlock) {
wrapper = null; // 遇到块级元素,重置包裹容器
} else {
// 如果是行内元素文本、图片、span、strong等放入 p 标签
// 忽略纯空白文本节点(如果它们在块级元素之间),避免产生过多的空段落
// 但为了保险起见(避免吞掉有意义的空格),这里暂时全部包裹
if (!wrapper) {
wrapper = document.createElement('p');
__c.insertBefore(wrapper, node);
flushInlines();
newContainer.appendChild(node);
// 记录最后添加的是块级元素
lastWasBlock = true;
} else if (node.nodeName === 'BR') {
if (currentInlineNodes.length > 0) {
flushInlines();
lastWasBlock = false; // 刚刚结束了一个段落,不算紧挨着块级
} else {
// 如果前面紧挨着块级元素,忽略这个 BR避免块级元素后的换行产生空行
if (lastWasBlock) {
// 忽略
lastWasBlock = false; // 消耗掉块级后的换行状态,避免连续 BR 被吞
} else {
const p = document.createElement('p');
p.innerHTML = '<br>';
newContainer.appendChild(p);
lastWasBlock = false;
}
}
wrapper.appendChild(node);
} else {
// 过滤掉块级元素之间或开头的纯空白文本节点,避免产生空的 P 标签
if (node.nodeType === 3 && !node.textContent.trim()) {
if (currentInlineNodes.length > 0) {
currentInlineNodes.push(node);
}
} else {
currentInlineNodes.push(node);
}
lastWasBlock = false;
}
});
html = __c.innerHTML;
flushInlines();
html = newContainer.innerHTML;
console.log("初始化显示的html", html);
return html;
@@ -563,6 +585,8 @@ const editApp = createApp({
// 提交
const submit = (status) => {
if (location.hostname == "127.0.0.1") status = 0
if (realname.value == 0 && userInfoWin.value?.uin > 0) {
openAttest();
return;
@@ -587,18 +611,15 @@ const editApp = createApp({
info.value["attachments"]["images"] = images;
info.value["attachments"]["videos"] = videos;
console.log("原始html", content);
// console.log("原始html", content);
content = formatContent(content);
console.log("最终html", content);
// console.log("最终html", content);
const data = {
...infoTarget,
content,
};
console.log("data", data);
if (location.hostname == "127.0.0.1") {
status = 0;
}
// console.log("data", data);
ajax("/v2/api/forum/postPublishTopic", {
info: data,
@@ -623,6 +644,8 @@ const editApp = createApp({
const formatContent = (html) => {
if (!html) return "";
// <p><br></p> 转换为单个换行符
html = html.replace(/<p><br><\/p>/gi, "\n");
// 1. 处理居中 [align=center] (p, div, h1-h6)
html = html.replace(/<(p|div|h[1-6])[^>]*style="[^"]*text-align:\s*center;[^"]*"[^>]*>([\s\S]*?)<\/\1>/gi, (match, tag, content) => {
@@ -637,7 +660,10 @@ const editApp = createApp({
html = html.replace(/<h1[^>]*>([\s\S]*?)<\/h1>/gi, "[section]$1[/section]");
// 2.5 处理段落 [p]
html = html.replace(/<p[^>]*>([\s\S]*?)<\/p>/gi, "[p]$1[/p]");
// 优先处理空段落 <p><br></p>,将其替换为单个换行符,避免后续双重换行
html = html.replace(/<p[^>]*>\s*<br\s*\/?>\s*<\/p>/gi, "\n");
// 处理普通段落结束符
html = html.replace(/<\/p>/gi, "\n");
// 3. 处理加粗 [b] (对应 strong, b)
html = html.replace(/<(strong|b)[^>]*>([\s\S]*?)<\/\1>/gi, "[b]$2[/b]");
@@ -667,7 +693,7 @@ const editApp = createApp({
// 匹配数字+单位 (px或%)
const wMatch = styleMatch[1].match(/width:\s*([\d.]+(?:px|%)?)/i);
const hMatch = styleMatch[1].match(/height:\s*([\d.]+(?:px|%)?)/i);
if (wMatch) {
// 如果是百分比,直接保留字符串;如果是纯数字默认视为 px如果是 px 去掉单位
let val = wMatch[1];
@@ -703,14 +729,16 @@ const editApp = createApp({
// 处理 data-href包裹在 [url] 中
const hrefMatch = imgTag.match(/data-href="([^"]+)"/i);
if (hrefMatch && hrefMatch[1]) {
result = `[url=${hrefMatch[1]}]${result}[/url]`;
}
if (hrefMatch && hrefMatch[1]) result = `[url=${hrefMatch[1]}]${result}[/url]`;
return result;
});
// 5. 处理视频 [attach]
html = html.replace(/<div[^>]*data-w-e-type="video"[^>]*>([\s\S]*?)<\/div>/gi, (match, content) => {
return content.trim(); // 去掉包裹视频的 div并去除首尾空白防止产生额外换行
});
html = html.replace(/<video[^>]*>[\s\S]*?<\/video>/gi, (videoTag) => {
let aid = "";
const dataAid = videoTag.match(/data-aid="(\d+)"/i);
@@ -742,7 +770,7 @@ const editApp = createApp({
// 7. 换行处理
html = html.replace(/<br\s*\/?>/gi, "\n");
// html = html.replace(/<\/(p|div)>/gi, "\n"); // 块级元素结束视为换行
html = html.replace(/<\/(div)>/gi, "\n"); // 块级元素结束视为换行
// 8. 清理剩余标签和解码
@@ -781,8 +809,6 @@ const editApp = createApp({
});
});
console.log("提取完成的图片列表:", images);
return images;
};
@@ -817,7 +843,6 @@ const editApp = createApp({
});
});
console.log("提取完成的视频列表:", result);
return result;
};
@@ -842,7 +867,6 @@ const editApp = createApp({
},
onUploadProgress: (e) => {
progress.value = Math.round((e.loaded / e.total) * 100);
console.log("progress.value", progress.value);
},
withCredentials: true,
})