From 1b5cf793003cd488d709e29dd43834d137109dfd Mon Sep 17 00:00:00 2001 From: "DESKTOP-RQ919RC\\Pc" <1300399510@qq.com> Date: Fri, 28 Nov 2025 19:23:01 +0800 Subject: [PATCH] =?UTF-8?q?refactor(editor):=20=E4=BC=98=E5=8C=96=E5=AF=8C?= =?UTF-8?q?=E6=96=87=E6=9C=AC=E5=86=85=E5=AE=B9=E8=BD=AC=E6=8D=A2=E9=80=BB?= =?UTF-8?q?=E8=BE=91=E5=B9=B6=E4=BF=AE=E5=A4=8D=E5=9B=BE=E7=89=87=E5=A4=84?= =?UTF-8?q?=E7=90=86=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 重构 restoreHtml 和 formatContent 函数,改进 BBCode 标记与 HTML 的相互转换 - 修复 parseImageSrc 函数在失败时未返回空字符串的问题 - 改进图片宽高处理,支持百分比单位和更精确的尺寸控制 - 增强视频和附件处理逻辑,完善资源匹配机制 - 优化 HTML 结构处理,确保符合编辑器的块级元素要求 --- js/edit.js | 354 ++++++++++++++++++++++++++++++++--------------------- 1 file changed, 213 insertions(+), 141 deletions(-) diff --git a/js/edit.js b/js/edit.js index 7b035d4..922a87f 100644 --- a/js/edit.js +++ b/js/edit.js @@ -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. 将所有
转为

,

转为

+ // 0.5 [p] ->

+ html = html.replace(/\[p\]([\s\S]*?)\[\/p\]/gi, "

$1

"); + + // 0. 基础清理:换行符转
,div 转 p (保持原有逻辑) + html = html.replace(/\n/g, "
"); html = html.replace(/)/gi, "/g, "

"); - - // 1. 还原换行符为
标签 - html = html.replace(/\n/g, "
"); - - // 2. 还原块级标签的换行标记 html = html.replace(/
/g, "
"); html = html.replace(/<\/div>
/g, "
"); + html = html.replace(/

/g, "

"); + html = html.replace(/<\/p>
/g, "

"); - // 3. 还原标签标记为span.blue - // html = html.replace(/\[tag\]([^[]+)\[\/tag\]/gi, '#$1 '); + // 1. [section] -> h1 + html = html.replace(/\[section\]([\s\S]*?)\[\/section\]/gi, "

$1

"); - // 4. 还原粗体标记为h2标签 - html = html.replace(/\[b\]([\s\S]*?)\[\/b\]/gi, "

$1

"); - // html = html.replace(/\[strong\]([\s\S]*?)\[\/strong\]/gi, "$1"); - // html = html.replace(/\[center\]([\s\S]*?)\[\/center\]/gi, '

$1

'); - // console.log("html1", html); + // 2. [b] -> strong + html = html.replace(/\[b\]([\s\S]*?)\[\/b\]/gi, "$1"); - // 5. 还原 a > [img] 的情况 - html = html.replace(/]*>\[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(/

\s*\[align=center\]([\s\S]*?)\[\/align\]\s*<\/h1>/gi, '

$1

'); + // 普通居中 + html = html.replace(/\[align=center\]([\s\S]*?)\[\/align\]/gi, '

$1

'); + + // 定义图片处理函数 + 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 ``; + 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 ``; + }; + + // 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 ``; + // 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 ``; - } - 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 `
`; - } - - // 查找对应的视频信息 - 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 `

`; } + // 尝试匹配图片 + 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 `
`; + } + // 尝试匹配文件 + const file = filesList.find((f) => String(f.aid) === String(aid)); + if (file) { + return `
附件: ${file.name || "Download"}`; + } - return match; // 未找到对应图片时保留原始标记 + return match; }); - // 6. 还原填充标签 - // html = html.replace(/([^<]+<\/span>)\s+/gi, '$1 '); - - // 7. 清理多余的
标签 - // html = html.replace(/

/g, "
"); + // 8. [url] (普通链接) + html = html.replace(/\[url=([^\]]+)\]([\s\S]*?)\[\/url\]/gi, '$2'); + // 9. 剩余资源追加 imageList.forEach((element) => (html += `
`)); - videosList.forEach((element) => (html += `
`)); + // 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); - } - }); - 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); + + // 确保所有顶层元素都是块级元素 (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); + } + 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 `

[align=center]${content}[/align]

`; + } + return `[align=center]${content}[/align]`; + }); + + // 2. 处理章节 [section] (对应 h1) + html = html.replace(/]*>([\s\S]*?)<\/h1>/gi, "[section]$1[/section]"); + + // 2.5 处理段落 [p] + html = html.replace(/]*>([\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(/]*>/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 (!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 (styleMatch) { + // 匹配数字+单位 (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]; + 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 = `${result}`; + // 处理 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(/]*>[\s\S]*?<\/video>/gi, (videoTag) => { - // 第一步:提取video内source标签的src属性 - const sourceSrcMatch = videoTag.match(/]*>/i); let aid = ""; - if (sourceSrcMatch && sourceSrcMatch[1]) { - // 从source的src中提取aid - const aidMatch = sourceSrcMatch[1].match(/aid=(\d+)/); - if (aidMatch) aid = aidMatch[1]; - } + const dataAid = videoTag.match(/data-aid="(\d+)"/i); + if (dataAid) aid = dataAid[1]; - // 第二步:兼容原有video标签的aid属性(如果source中没有,取video的aid) if (!aid) { - const videoAidMatch = videoTag.match(/aid="(\d+)"/i); - if (videoAidMatch) aid = videoAidMatch[1]; + const srcMatch = videoTag.match(/src="([^"]+)"/i); + if (srcMatch) { + const aidMatch = srcMatch[1].match(/[?&]aid=(\d+)/); + if (aidMatch) aid = aidMatch[1]; + } } - - // 无aid则返回原标签,有则生成指定格式 return aid ? `[attach]${aid}[/attach]` : ""; }); - // 2. 替换H2标签 - html = html.replace(/]*>([\s\S]*?)<\/h1>/gi, "[section]$1[/section]"); - html = html.replace(/]*>([\s\S]*?)<\/strong>/gi, "[b]$1[/b]"); + // 6. 处理链接 [url] 和 附件下载 [attach] + html = html.replace(/]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/gi, (match, href, content) => { + // 尝试提取 aid + const aidMatch = href.match(/[?&]aid=(\d+)/); - // 3.ghj hgj ghj 替换为 [url=ghj hgj gh jghj ]ghj hgj ghj [/url] - html = html.replace(/([\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. 处理
为换行 + // 7. 换行处理 html = html.replace(//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 = { + " ": " ", + "<": "<", + ">": ">", + "&": "&", + """: '"', + "'": "'", + }; + html = html.replace(/&[a-z]+;/gi, (match) => entities[match] || match); + + return html.trim(); }; const extractImages = (dom) => {