refactor(editor): 优化富文本内容转换逻辑并修复图片处理问题

- 重构 restoreHtml 和 formatContent 函数,改进 BBCode 标记与 HTML 的相互转换
- 修复 parseImageSrc 函数在失败时未返回空字符串的问题
- 改进图片宽高处理,支持百分比单位和更精确的尺寸控制
- 增强视频和附件处理逻辑,完善资源匹配机制
- 优化 HTML 结构处理,确保符合编辑器的块级元素要求
This commit is contained in:
DESKTOP-RQ919RC\Pc
2025-11-28 19:23:01 +08:00
parent 0960a310aa
commit 1b5cf79300

View File

@@ -227,6 +227,7 @@ const editApp = createApp({
console.log("href", href);
},
async parseImageSrc(src) {
console.log("parseImageSrc", src);
// 如果图片链接中已经包含了 ?aid= ,则说明是本站图片,直接返回,无需处理
if (src.includes("?aid=")) return src;
@@ -242,11 +243,13 @@ const editApp = createApp({
const res = await ajax(uConfigData.url, formData);
if (res.code == 200 && res.data) return `${res.data.url}?aid=${res.data.aid}`;
else creationAlertBox("error", res.message || "操作失败");
else {
creationAlertBox("error", res.message || "操作失败");
return "";
}
} catch (e) {
console.error("Transform network image failed", e);
}
return src;
},
},
@@ -400,126 +403,141 @@ const editApp = createApp({
const restoreHtml = (formattedText, attachments) => {
const imageList = attachments?.images || [];
const filesList = attachments?.files || [];
const videosList = attachments?.videos || [];
let html = formattedText;
let html = formattedText || "";
// 0. 将所有 <div> 转为 <p>, </div>转为</p>
// 0.5 [p] -> <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(/<div(\s|>)/gi, "<p$1");
html = html.replace(/<\/div>/g, "</p>");
// 1. 还原换行符为<br>标签
html = html.replace(/\n/g, "<br>");
// 2. 还原块级标签的换行标记
html = html.replace(/<br><div>/g, "<div>");
html = html.replace(/<\/div><br>/g, "</div>");
html = html.replace(/<br><p>/g, "<p>");
html = html.replace(/<\/p><br>/g, "</p>");
// 3. 还原标签标记为span.blue
// html = html.replace(/\[tag\]([^[]+)\[\/tag\]/gi, '<span class="blue">#$1</span> <span class="fill"></span> ');
// 1. [section] -> h1
html = html.replace(/\[section\]([\s\S]*?)\[\/section\]/gi, "<h1>$1</h1>");
// 4. 还原粗体标记为h2标签
html = html.replace(/\[b\]([\s\S]*?)\[\/b\]/gi, "<h1>$1</h1>");
// html = html.replace(/\[strong\]([\s\S]*?)\[\/strong\]/gi, "<strong>$1</strong>");
// html = html.replace(/\[center\]([\s\S]*?)\[\/center\]/gi, '<p style="text-align: center;">$1</p>');
// console.log("html1", html);
// 2. [b] -> strong
html = html.replace(/\[b\]([\s\S]*?)\[\/b\]/gi, "<strong>$1</strong>");
// 5. 还原 a > [img] 的情况
html = html.replace(/<a href="([^\"]+)"[^>]*>\[img(?:=([0-9]+(?:\.[0-9]+)?)(?:,([0-9]+(?:\.[0-9]+)?))?)?\](\d+)\[\/img\]<\/a>/gi, (match, href, width, height, aid) => {
// 3. [align=center]
// 特殊处理嵌套在 h1 中的居中
html = html.replace(/<h1>\s*\[align=center\]([\s\S]*?)\[\/align\]\s*<\/h1>/gi, '<h1 style="text-align: center;">$1</h1>');
// 普通居中
html = html.replace(/\[align=center\]([\s\S]*?)\[\/align\]/gi, '<p style="text-align: center;">$1</p>');
// 定义图片处理函数
const processImg = (aid, width, height, href) => {
const image = imageList.find((img) => String(img.aid) === String(aid));
if (!image) return match;
if (!image) return "";
// 移除已使用的图片
const index = imageList.findIndex((img) => String(img.aid) === String(aid));
if (index > -1) imageList.splice(index, 1);
let style = "";
const w = width ? Number(width) : 0;
const h = height ? Number(height) : 0;
if (w > 0 && h > 0) style = `style="width: ${w}px; height: ${h}px;"`;
else if (w > 0) style = `style="width: ${w}px;"`;
const formatStyleVal = (val) => {
if (!val) return null;
if (String(val).endsWith('%')) return val;
const num = Number(val);
return num > 0 ? `${num}px` : null;
};
return `<img src="${image.url}?aid=${aid}" data-aid="${aid}" data-href="${href}" ${style}>`;
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}"` : "";
return `<img src="${image.url}?aid=${aid}" data-aid="${aid}"${dataHref} ${style}>`;
};
// 4. [url] + [img] 组合 (带链接的图片)
// 匹配 [url=...][img...]...[/img][/url]
html = html.replace(/\[url=([^\]]+)\]\[img(?:=([0-9.%]+)(?:,([0-9.%]+))?)?\](\d+)\[\/img\]\[\/url\]/gi, (match, href, w, h, aid) => {
const imgTag = processImg(aid, w, h, href);
return imgTag || match;
});
// 5. 还原【新增图片格式】[img=width,height]aid[/img] 或 [img]aid[/img]
html = html.replace(/\[img(?:=([0-9]+(?:\.[0-9]+)?)(?:,([0-9]+(?:\.[0-9]+)?))?)?\](\d+)\[\/img\]/gi, (match, width, height, aid) => {
const image = imageList.find((img) => String(img.aid) === String(aid)); // 统一字符串比较,避免类型问题
if (!image) return match;
// 从列表中移除已匹配的图片(避免重复使用)
const index = imageList.findIndex((img) => String(img.aid) === String(aid));
if (index > -1) imageList.splice(index, 1);
// 拼接img标签带宽高样式宽高为0则不设置
let style = "";
const w = width ? Number(width) : 0;
const h = height ? Number(height) : 0;
if (w > 0 && h > 0) style = `style="width: ${w}px; height: ${h}px;"`;
else if (w > 0) style = `style="width: ${w}px;"`;
return `<img src="${image.url}?aid=${aid}" data-aid="${aid}" ${style}>`;
// 5. 单独 [img]
html = html.replace(/\[img(?:=([0-9.%]+)(?:,([0-9.%]+))?)?\](\d+)\[\/img\]/gi, (match, w, h, aid) => {
const imgTag = processImg(aid, w, h, null);
return imgTag || match;
});
// console.log("html2", html);
// 5. 还原图片标记为img标签使用提供的imageList
// 6. [attachimg] (兼容旧格式)
html = html.replace(/\[attachimg\](\d+)\[\/attachimg\]/gi, (match, aid) => {
// 查找对应的图片信息
const image = imageList.find((img) => img.aid == aid);
if (image) {
imageList.splice(imageList.indexOf(image), 1);
return `<img src="${image.url}?aid=${aid}" data-aid="${aid}">`;
}
return match; // 未找到对应图片时保留原始标记
const imgTag = processImg(aid, 0, 0, null);
return imgTag || match;
});
// 7. [attach] (图片、视频或附件)
html = html.replace(/\[attach\](\d+)\[\/attach\]/gi, (match, aid) => {
// 查找对应的图片信息
const image = imageList.find((img) => img.aid == aid);
if (image) {
imageList.splice(imageList.indexOf(image), 1);
return `<img src="${image.url}?aid=${aid}"><br/>`;
}
// 查找对应的视频信息
const video = videosList.find((v) => v.aid == aid);
// 尝试匹配视频
const video = videosList.find((v) => String(v.aid) === String(aid));
if (video) {
console.log(video);
videosList.splice(videosList.indexOf(video), 1);
const index = videosList.findIndex((v) => String(v.aid) === String(aid));
if (index > -1) videosList.splice(index, 1);
return `<p><video src="${video.url}?aid=${video.aid}" preload="none" poster="${video.posterurl}?aid=${video.posterid}" controls></video></p>`;
}
// 尝试匹配图片
const image = imageList.find((img) => String(img.aid) === String(aid));
if (image) {
const index = imageList.findIndex((img) => String(img.aid) === String(aid));
if (index > -1) imageList.splice(index, 1);
return `<img src="${image.url}?aid=${image.aid}"><br/>`;
}
// 尝试匹配文件
const file = filesList.find((f) => String(f.aid) === String(aid));
if (file) {
return `<a href="${file.url}?aid=${aid}" target="_blank">附件: ${file.name || "Download"}</a>`;
}
return match; // 未找到对应图片时保留原始标记
return match;
});
// 6. 还原填充标签
// html = html.replace(/(<span class="blue">[^<]+<\/span>)\s+/gi, '$1 <span class="fill"></span> ');
// 7. 清理多余的<br>标签
// html = html.replace(/<br><br>/g, "<br>");
// 8. [url] (普通链接)
html = html.replace(/\[url=([^\]]+)\]([\s\S]*?)\[\/url\]/gi, '<a href="$1" target="_blank">$2</a>');
// 9. 剩余资源追加
imageList.forEach((element) => (html += `<img src="${element.url}?aid=${element.aid}"><br/>`));
videosList.forEach((element) => (html += `<video src="${element.url}?aid=${element.aid}" preload="none" poster="${element.posterurl}?aid=${element.posterid}" controls></video><br/>`));
// 10. 最终 HTML 结构包裹
const __c = document.createElement("div");
__c.innerHTML = html;
Array.from(__c.querySelectorAll("img")).forEach((im) => {
const p = im.parentElement;
if (!p || p.tagName !== "P") {
const wrap = document.createElement("p");
p ? p.insertBefore(wrap, im) : __c.appendChild(wrap);
wrap.appendChild(im);
// 确保所有顶层元素都是块级元素 (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;
children.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);
}
});
Array.from(__c.querySelectorAll("video")).forEach((vd) => {
const p = vd.parentElement;
if (!p || p.tagName !== "P") {
const wrap = document.createElement("p");
p ? p.insertBefore(wrap, vd) : __c.appendChild(wrap);
wrap.appendChild(vd);
wrapper.appendChild(node);
}
});
@@ -578,7 +596,9 @@ const editApp = createApp({
};
console.log("data", data);
if (location.hostname == "127.0.0.1") return;
if (location.hostname == "127.0.0.1") {
status = 0;
}
ajax("/v2/api/forum/postPublishTopic", {
info: data,
@@ -602,92 +622,144 @@ const editApp = createApp({
};
const formatContent = (html) => {
// 1. 替换图片标签优先解析src中的aid+宽高生成自定义格式再兼容原有data-aid逻辑
if (!html) return "";
// 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) => {
if (tag.toLowerCase() === "h1") {
// h1 特殊处理:保留标签,内容居中,供后续转 [section]
return `<h1>[align=center]${content}[/align]</h1>`;
}
return `[align=center]${content}[/align]`;
});
// 2. 处理章节 [section] (对应 h1)
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]");
// 3. 处理加粗 [b] (对应 strong, b)
html = html.replace(/<(strong|b)[^>]*>([\s\S]*?)<\/\1>/gi, "[b]$2[/b]");
// 4. 处理图片 [img]
html = html.replace(/<img[^>]*>/gi, (imgTag) => {
const srcMatch = imgTag.match(/src="([^"]+)"/i);
let aid = "";
if (srcMatch && srcMatch[1]) {
const aidMatch = srcMatch[1].match(/aid=(\d+)/);
// 尝试从 src 中获取 aid
const srcMatch = imgTag.match(/src="([^"]+)"/i);
if (srcMatch) {
const aidMatch = srcMatch[1].match(/[?&]aid=(\d+)/);
if (aidMatch) aid = aidMatch[1];
}
// 尝试从 data-aid 中获取 aid
if (!aid) {
const dataAidMatch = imgTag.match(/data-aid="(\d+)"/i);
if (dataAidMatch) aid = dataAidMatch[1];
const dataAid = imgTag.match(/data-aid="(\d+)"/i);
if (dataAid) aid = dataAid[1];
}
if (!aid) return imgTag;
if (!aid) return ""; // 无法获取 aid跳过
// 获取宽高 (支持 px 和 %)
let w = 0,
h = 0;
const styleMatch = imgTag.match(/style="([^"]+)"/i);
let width = 0,
height = 0;
if (styleMatch && styleMatch[1]) {
const widthMatch = styleMatch[1].match(/width:\s*(\d+(?:\.\d+)?)px/i);
const heightMatch = styleMatch[1].match(/height:\s*(\d+(?:\.\d+)?)px/i);
width = widthMatch ? Number(widthMatch[1]) : 0;
height = heightMatch ? Number(heightMatch[1]) : 0;
if (styleMatch) {
// 匹配数字+单位 (px或%)
const wMatch = styleMatch[1].match(/width:\s*([\d.]+(?:px|%)?)/i);
const hMatch = styleMatch[1].match(/height:\s*([\d.]+(?:px|%)?)/i);
if (!width) {
const widthPctMatch = styleMatch[1].match(/width:\s*(\d+(?:\.\d+)?)%/i);
if (widthPctMatch) {
const el = (editorRef && editorRef.value) || document.querySelector("#editor-container");
const boxW = el ? el.getBoundingClientRect().width || el.clientWidth || 0 : 0;
const pct = Number(widthPctMatch[1]);
if (boxW && pct > 0) width = Math.round((pct / 100) * boxW);
if (wMatch) {
// 如果是百分比,直接保留字符串;如果是纯数字默认视为 px如果是 px 去掉单位
let val = wMatch[1];
if (val.endsWith('%')) w = val; // 保留百分比字符串
else w = parseFloat(val); // 转为数字 (px)
}
if (hMatch) {
let val = hMatch[1];
if (val.endsWith('%')) h = val;
else h = parseFloat(val);
}
}
// 兼容 width/height 属性 (通常只有数字)
if (!w) {
const wAttr = imgTag.match(/\swidth="(\d+)"/i);
if (wAttr) w = Number(wAttr[1]);
}
if (!h) {
const hAttr = imgTag.match(/\sheight="(\d+)"/i);
if (hAttr) h = Number(hAttr[1]);
}
console.log("width", width, "height", height);
let result = "";
if (w || h) { // 只要有一个有值
const formatVal = (val) => {
if (typeof val === 'string' && val.endsWith('%')) return val;
return val ? parseFloat(Number(val).toFixed(2)) : 0;
};
result = `[img=${formatVal(w)},${formatVal(h)}]${aid}[/img]`;
} else {
result = `[img]${aid}[/img]`;
}
// 第四步:按规则生成格式
let result;
if (width == 0 && height == 0) result = `[img]${aid}[/img]`;
else result = `[img=${width}${height ? "," + height : ""}]${aid}[/img]`;
// 提取 data-href 并添加 a 标签
const dataHrefMatch = imgTag.match(/data-href="([^"]+)"/i);
if (dataHrefMatch && dataHrefMatch[1]) result = `<a href="${dataHrefMatch[1]}" target="_blank">${result}</a>`;
// 处理 data-href包裹在 [url] 中
const hrefMatch = imgTag.match(/data-href="([^"]+)"/i);
if (hrefMatch && hrefMatch[1]) {
result = `[url=${hrefMatch[1]}]${result}[/url]`;
}
return result;
});
// 1.1 替换视频标签
// 5. 处理视频 [attach]
html = html.replace(/<video[^>]*>[\s\S]*?<\/video>/gi, (videoTag) => {
// 第一步提取video内source标签的src属性
const sourceSrcMatch = videoTag.match(/<source\s+src="([^"]+)"[^>]*>/i);
let aid = "";
if (sourceSrcMatch && sourceSrcMatch[1]) {
// 从source的src中提取aid
const aidMatch = sourceSrcMatch[1].match(/aid=(\d+)/);
const dataAid = videoTag.match(/data-aid="(\d+)"/i);
if (dataAid) aid = dataAid[1];
if (!aid) {
const srcMatch = videoTag.match(/src="([^"]+)"/i);
if (srcMatch) {
const aidMatch = srcMatch[1].match(/[?&]aid=(\d+)/);
if (aidMatch) aid = aidMatch[1];
}
// 第二步兼容原有video标签的aid属性如果source中没有取video的aid
if (!aid) {
const videoAidMatch = videoTag.match(/aid="(\d+)"/i);
if (videoAidMatch) aid = videoAidMatch[1];
}
// 无aid则返回原标签有则生成指定格式
return aid ? `[attach]${aid}[/attach]` : "";
});
// 2. 替换H2标签
html = html.replace(/<h1[^>]*>([\s\S]*?)<\/h1>/gi, "[section]$1[/section]");
html = html.replace(/<strong[^>]*>([\s\S]*?)<\/strong>/gi, "[b]$1[/b]");
// 6. 处理链接 [url] 和 附件下载 [attach]
html = html.replace(/<a[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/gi, (match, href, content) => {
// 尝试提取 aid
const aidMatch = href.match(/[?&]aid=(\d+)/);
// 3.<a href="ghj hgj gh jghj " target="_blank">ghj hgj ghj </a> 替换为 [url=ghj hgj gh jghj ]ghj hgj ghj [/url]
html = html.replace(/<a\s+href="([^"]+)"\s+target="_blank">([\s\S]*?)<\/a>/gi, (match, href, content) => {
// 如果是下载链接(包含 download 属性 或 明确是附件)且有 aid转为 [attach]
if (match.includes("download=") && aidMatch) {
return `[attach]${aidMatch[1]}[/attach]`;
}
// 普通链接
return `[url=${href}]${content}[/url]`;
});
// 6. 处理<br>为换行
// 7. 换行处理
html = html.replace(/<br\s*\/?>/gi, "\n");
// html = html.replace(/<\/(p|div)>/gi, "\n"); // 块级元素结束视为换行
html = html.replace(/<\/(div)>/gi, "\n"); // 块级元素结束视为换行
// 去除首尾空白
html = html.trim();
// 8. 清理剩余标签和解码
html = html.replace(/<[^>]+>/g, ""); // 移除所有剩余标签
return html;
// 简单的 HTML 实体解码
const entities = {
"&nbsp;": " ",
"&lt;": "<",
"&gt;": ">",
"&amp;": "&",
"&quot;": '"',
"&apos;": "'",
};
html = html.replace(/&[a-z]+;/gi, (match) => entities[match] || match);
return html.trim();
};
const extractImages = (dom) => {