Files
PC-Light-Forum/js/edit.js
A1300399510 f7af6d4046 feat(editor): 实现自定义居中按钮并优化媒体上传处理
- 添加自定义居中按钮功能,替换原生的居中实现
- 修改图片和视频上传后的URL格式,附加aid参数
- 优化媒体资源提取逻辑,处理URL中的查询参数
- 调整编辑器工具栏的z-index样式
- 移除不必要的position属性
2025-11-27 02:50:45 +08:00

1171 lines
47 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 简单版本的论坛编辑器,确保图片插入功能正常
const { createApp, ref, computed, onMounted, nextTick, onUnmounted } = Vue;
import { headTop } from "../component/head-top/head-top.js";
const { createEditor, createToolbar, SlateTransforms, Boot, SlateEditor } = window.wangEditor;
console.log("createEditor", createEditor);
class MyButtonMenu { // JS 语法
constructor() {
this.title = '居中' // 自定义菜单标题
// this.iconSvg = '<svg>...</svg>' // 可选
this.tag = 'button'
}
// 获取菜单执行时的 value ,用不到则返回空 字符串或 false
getValue(editor) {
// console.log("getValue", editor);
// TS 语法
// getValue(editor) { // JS 语法
return ' hello '
}
// 菜单是否需要激活(如选中加粗文本,“加粗”菜单会激活),用不到则返回 false
isActive(editor) {
console.log("isActive", editor.getFragment()?.[0]);
if (editor.getFragment()?.[0]?.textAlign == 'center') return true
return false
}
// 菜单是否需要禁用(如选中 H1 ,“引用”菜单被禁用),用不到则返回 false
isDisabled(editor) {
// TS 语法
// isDisabled(editor) { // JS 语法
return false
}
// 点击菜单时触发的函数
exec(editor, value) {
// center
let align = this.isActive(editor) ? 'left' : 'center'
console.log("align", this.isActive(editor));
SlateTransforms.setNodes(editor, {
textAlign: align
},)
}
}
const editApp = createApp({
setup() {
let titleLength = ref(200);
let uniqid = ref("");
onMounted(() => {
const params = getUrlParams();
uniqid.value = params.uniqid || "";
getUserInfoWin();
checkWConfig();
cUpload();
// 添加selectionchange事件监听当鼠标选中区域内容时更新lastSelection
document.addEventListener("selectionchange", handleSelectionChange);
});
// 组件卸载时移除事件监听
onUnmounted(() => {
document.removeEventListener("selectionchange", handleSelectionChange);
});
let imageLength = 10;
let videoLength = 5;
const checkWConfig = () => {
const wConfig = JSON.parse(localStorage.getItem("wConfig")) || {};
console.log("wConfig", wConfig);
if (wConfig.time) {
const time = new Date(wConfig.time);
const now = new Date();
if (now - time > 24 * 60 * 60 * 1000) getWConfig();
else {
const config = wConfig.config || {};
titleLength.value = config.max_topic_title_length;
imageLength = config.topic_image_count || 0;
videoLength = config.topic_video_count || 0;
}
} else {
getWConfig();
}
};
const getWConfig = () => {
ajaxGet("/v2/api/config/website").then((res) => {
if (res.code == 200) {
let data = res["data"] || {};
const config = data.config || {};
titleLength.value = config.max_topic_title_length;
imageLength = config.topic_image_count || 0;
videoLength = config.topic_video_count || 0;
data.time = new Date().toISOString();
localStorage.setItem("wConfig", JSON.stringify(data));
}
});
};
let isLogin = ref(false);
let realname = ref(0); // 是否已经实名
let userInfoWin = ref({});
let permissions = ref([]);
const getUserInfoWin = () => {
const checkUser = () => {
const user = window.userInfoWin;
if (!user) return;
document.removeEventListener("getUser", checkUser);
realname.value = user.realname;
userInfoWin.value = user;
if (user?.uin > 0 || user?.uid > 0) isLogin.value = true;
permissions.value = user?.authority || [];
};
document.addEventListener("getUser", checkUser);
};
const openAttest = () => {
const handleAttestClose = () => {
document.removeEventListener("closeAttest", handleAttestClose);
realname.value = window.userInfoWin?.realname || 0;
};
// 启动认证流程时添加监听
document.addEventListener("closeAttest", handleAttestClose);
loadAttest(2);
};
// 跳转登录
const goLogin = () => {
if (typeof window === "undefined") return;
if (window["userInfoWin"] && Object.keys(window["userInfoWin"]).length !== 0) {
if (window["userInfoWin"]["uid"]) isLogin.value = true;
else ajax_login();
} else ajax_login();
};
let uConfigData = {};
const cUpload = () => {
ajaxGet(`/v2/api/config/upload?type=topic`).then((res) => {
const data = res.data;
uConfigData = data;
console.log("uConfigData", uConfigData);
init();
});
};
let info = ref({});
let tagList = ref([]);
let token = ref("");
let infoImages = [];
const init = () => {
ajax("/v2/api/forum/postPublishInit", {
uniqid: uniqid.value,
})
.then((res) => {
const data = res.data;
if (res.code != 200) {
creationAlertBox("error", res.message || "操作失败");
return;
}
const infoTarget = data.info || {};
if (infoTarget.content) infoTarget.content = restoreHtml(infoTarget.content, infoTarget.attachments);
info.value = infoTarget;
token.value = data.token;
// nextTick(() => {
// judgeIsEmpty();
// });
initEditor();
})
.catch((err) => {
console.log("err", err);
});
};
let editor = null;
let toolbarRef = ref(null);
const initEditor = () => {
let infoTarget = info.value || {};
console.log("infoTarget", infoTarget);
// 转换图片链接
async function customCheckImageFn(src, alt, url) {
// JS 语法
if (!src) {
return;
}
await setTimeout(() => {
console.log("1111");
return true;
}, 2000);
// setTimeout(() => {
// return "图片网址必须以 http/https 开头";
// }, 2000);
if (src.indexOf("http") !== 0) {
// return "图片网址必须以 http/https 开头";
}
// return true;
// 返回值有三种选择:
// 1. 返回 true ,说明检查通过,编辑器将正常插入图片
// 2. 返回一个字符串,说明检查未通过,编辑器会阻止插入。会 alert 出错误信息(即返回的字符串)
// 3. 返回 undefined即没有任何返回说明检查未通过编辑器会阻止插入。但不会提示任何信息
}
// // 自定义转换视频
// const customParseVideoSrc = (src) => {
// console.log("customParseVideoSrc", this);
// // TS 语法
// // function customParseVideoSrc(src) { // JS 语法
// if (src.includes('.bilibili.com')) {
// // 转换 bilibili url 为 iframe (仅作为示例,不保证代码正确和完整)
// const arr = location.pathname.split('/')
// const vid = arr[arr.length - 1]
// return `<iframe src="//player.bilibili.com/player.html?bvid=${vid}" scrolling="no" border="0" frameborder="no" framespacing="0" allowfullscreen="true"> </iframe>`
// }
// // return src
// return `<iframe src="${src}" scrolling="no" border="0" frameborder="no" framespacing="0" allowfullscreen="true"> </iframe>`
// // return `<video contenteditable="false" src="${src}" controls></video>`;
// }
const editorConfig = {
placeholder: "输入正文",
enabledMenus: [],
MENU_CONF: {
["emotion"]: {
emotions: optionEmoji.value,
},
["insertImage"]: {
onInsertedImage(imageNode) {
console.log("imageNode", imageNode);
// TS 语法
// onInsertedImage(imageNode) { // JS 语法
if (imageNode == null) return;
const { src, alt, url, href } = imageNode;
console.log("inserted image", src, alt, url, href);
},
// checkImage: async (src, alt, url) => await customCheckImageFn(src, alt, url), // 也支持 async 函数
},
["uploadImage"]: {
server: uConfigData.url,
// form-data fieldName ,默认值 'wangeditor-uploaded-image'
fieldName: uConfigData.requestName,
// 单个文件的最大体积限制,默认为 2M
maxFileSize: maxSize, // 1M
// 最多可上传几个文件,默认为 100
maxNumberOfFiles: imageLength,
// 选择文件时的类型限制,默认为 ['image/*'] 。如不想限制,则设置为 []
allowedFileTypes: ["image/*", ".png", ".jpg", ".jpeg"],
// 自定义上传参数,例如传递验证的 token 等。参数会被添加到 formData 中,一起上传到服务端。
meta: { ...uConfigData.params },
// 将 meta 拼接到 url 参数中,默认 false
metaWithUrl: false,
// 自定义增加 http header
headers: { accept: "application/json, text/plain, */*", ...uConfigData.headers },
// 跨域是否传递 cookie ,默认为 false
withCredentials: true,
// 超时时间,默认为 10 秒
async customUpload(file, insertFn) {
try {
let config = uConfigData;
// 1. 构造 FormData包含你的接口所需字段
const formData = new FormData();
formData.append(config.requestName, file); // 文件数据
formData.append("name", file.name); // 文件名
formData.append("type", "image"); // 文件名
formData.append("data", config.params.data); // 文件名
// uploading(file, file.name, "image").then((data) => {
// insertFn(data.url); // 传入图片的可访问 URL
// });
ajax(config.url, formData).then((res) => {
const data = res.data;
console.log("上传成功:", data);
insertFn(`${data.url}?aid=${data.aid}`); // 传入图片的可访问 URL
});
} catch (err) {
console.error("上传出错:", err);
}
},
},
['insertVideo']: {
onInsertedVideo(videoNode) {
// TS 语法
// onInsertedVideo(videoNode) { // JS 语法
if (videoNode == null) return
const { src } = videoNode
console.log('inserted video', src)
},
// checkVideo: customCheckVideoFn, // 也支持 async 函数
// parseVideoSrc: (src) => customParseVideoSrc(src), // 也支持 async 函数
},
["uploadVideo"]: {
server: uConfigData.url,
// form-data fieldName ,默认值 'wangeditor-uploaded-video'
fieldName: uConfigData.requestName,
// 单个文件的最大体积限制,默认为 10M
maxFileSize: maxSize, // 1M
// 最多可上传几个文件,默认为 5
maxNumberOfFiles: videoLength,
// 选择文件时的类型限制,默认为 ['video/*'] 。如不想限制,则设置为 []
allowedFileTypes: ["video/*"],
// 自定义上传参数,例如传递验证的 token 等。参数会被添加到 formData 中,一起上传到服务端。
meta: { ...uConfigData.params },
// 将 meta 拼接到 url 参数中,默认 false
metaWithUrl: false,
// 自定义增加 http header
headers: { accept: "application/json, text/plain, */*", ...uConfigData.headers },
// 跨域是否传递 cookie ,默认为 false
withCredentials: true,
// 超时时间,默认为 30 秒
timeout: 15 * 1000, // 15 秒
// 视频不支持 base64 格式插入
async customUpload(file, insertFn) {
try {
const videoUploadRes = await uploading(file, file.name, "video");
const coverFile = await getVideoFirstFrame(file);
console.log("第一帧提取成功", coverFile);
// 步骤3再上传第一帧封面type 传 'cover',按后端要求调整)
const coverUploadRes = await uploading(coverFile, coverFile.name, "image");
console.log("封面上传成功", coverUploadRes);
console.log(insertFn);
insertFn(`${videoUploadRes.url}?aid=${videoUploadRes.aid}`, `${coverUploadRes.url}?aid=${coverUploadRes.aid}`);
} catch (err) {
console.error("上传出错:", err);
}
},
},
},
onChange(editor) {
const html = editor.getHtml();
// console.log('"editor', editor);
console.log("editor content", html);
updateWHeadingStatus();
},
};
editor = createEditor({
selector: "#editor-container",
html: "<p><br>555</p>",
config: editorConfig,
mode: "default",
});
// const toolbar = DomEditor.getToolbar(editor)
const toolbarConfig = {
// toolbarKeys: ["bold", "italic", "list"],
toolbarKeys: [
"header2", // 标题
{
key: "group-image",
title: "图片",
menuKeys: ["insertImage", "uploadImage"],
},
{
key: "group-video",
title: "视频",
menuKeys: ["insertVideo", "uploadVideo"],
},
// "insertVideo",
"emotion", // 表情
// "uploadImage", // 插入图片
// "uploadVideo", // 插入视频
"insertLink", // 插入链接
"bold", // 粗体
// "justifyCenter",
],
};
const menu1Conf = {
key: 'customCenter', // 定义 menu key :要保证唯一、不重复(重要)
factory() {
return new MyButtonMenu() // 把 `YourMenuClass` 替换为你菜单的 class
},
}
Boot.registerMenu(menu1Conf)
console.log(toolbarConfig, "toolbarConfig");
toolbarConfig.insertKeys = {
index: 7, // 插入的位置,基于当前的 toolbarKeys
keys: ['customCenter'],
}
const toolbar = createToolbar({
editor,
selector: "#toolbar-container",
config: toolbarConfig,
mode: "default",
});
console.log("editor.commands", editor);
nextTick(() => {
const h2 = toolbarRef.value.querySelector('[data-menu-key="header2"]');
const h2Item = h2.parentElement;
h2Item.classList.add("toolbar-item", "flexacenter");
h2.innerHTML = '<img class="icon" src="{@/img/t-icon.png}" alt="段落标题" /> <span>段落标题</span>';
const image = toolbarRef.value.querySelector('[data-menu-key="group-image"]');
const imageItem = image.parentElement;
imageItem.classList.add("toolbar-item", "flexacenter");
image.innerHTML = '<img class="icon" src="{@/img/img-icon.png}" alt="图片" /> <span>图片</span>';
const video = toolbarRef.value.querySelector('[data-menu-key="group-video"]');
const videoItem = video.parentElement;
videoItem.classList.add("toolbar-item", "flexacenter");
video.innerHTML = '<img class="icon" src="{@/img/video-icon.png}" alt="视频" /> <span>视频</span>';
const emotion = toolbarRef.value.querySelector('[data-menu-key="emotion"]');
const emotionItem = emotion.parentElement;
emotionItem.classList.add("toolbar-item", "flexacenter");
emotion.innerHTML = '<img class="icon" src="{@/img/emotion-icon.png}" alt="表情" /> <span>表情</span>';
const link = toolbarRef.value.querySelector('[data-menu-key="insertLink"]');
const linkItem = link.parentElement;
linkItem.classList.add("toolbar-item", "flexacenter");
link.innerHTML = '<img class="icon" src="{@/img/link-icon.png}" alt="链接" /> <span>链接</span>';
const bold = toolbarRef.value.querySelector('[data-menu-key="bold"]');
const boldItem = bold.parentElement;
boldItem.classList.add("toolbar-item", "flexacenter");
bold.innerHTML = '<img class="icon" src="{@/img/bold-icon.png}" alt="粗体" /> <span>粗体</span>';
const customCenter = toolbarRef.value.querySelector('[data-menu-key="customCenter"]');
const customCenterItem = customCenter.parentElement;
customCenterItem.classList.add("toolbar-item", "flexacenter");
customCenter.innerHTML = '<img class="icon" src="{@/img/justify-center-icon.png}" alt="居中" /> <span>居中</span>';
});
};
const restoreHtml = (formattedText, attachments) => {
const imageList = attachments?.images || [];
const filesList = attachments?.files || [];
const videosList = attachments?.videos || [];
let html = formattedText;
// 1. 还原换行符为<br>标签
html = html.replace(/\n/g, "<br>");
// 2. 还原块级标签的换行标记
html = html.replace(/<br><div>/g, "<div>");
html = html.replace(/<\/div><br>/g, "</div>");
// 3. 还原标签标记为span.blue
html = html.replace(/\[tag\]([^[]+)\[\/tag\]/gi, '<span class="blue">#$1</span> <span class="fill"></span> ');
// 4. 还原粗体标记为h2标签
html = html.replace(/\[b\]([\s\S]*?)\[\/b\]/gi, "<h2>$1</h2>");
// 5. 还原图片标记为img标签使用提供的imageList
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}" data-aid="${aid}"><br/>`;
}
return match; // 未找到对应图片时保留原始标记
});
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}" data-aid="${aid}"><br/>`;
}
// 查找对应的视频信息
const video = videosList.find((v) => v.aid == aid);
if (video) {
console.log("video", video);
videosList.splice(videosList.indexOf(video), 1);
return `<video contenteditable="false" src="${video.url}" width="400" height="400" preload="none" poster="${video.posterurl}" aid="${video.aid}" posterid="${video.posterid}" controls></video>`;
}
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>");
imageList.forEach((element) => {
html += `<img src="${element.url}" data-aid="${element.aid}"><br/>`;
});
// video 不要预加载
videosList.forEach((element) => {
html += `<video contenteditable="false" src="${element.url}" width="400" height="400" preload="none" poster="${element.posterurl}" aid="${element.aid}" posterid="${element.posterid}" controls></video><br/>`;
});
return html;
};
onMounted(() => {
setTimeout(() => focusLastNode(), 1000);
// document.addEventListener("keydown", handleUndoKeydown);
});
const editorRef = ref(null);
const focusLastNode = () => {
return;
const newRange = document.createRange();
const textNode = document.createTextNode("");
editorRef.value.appendChild(textNode);
newRange.setStartAfter(textNode, 0);
newRange.setEndAfter(textNode, 0);
lastSelection = newRange;
};
let lastSelection = null;
let lastSelectionW = null;
const isH1 = ref(false);
const updateWHeadingStatus = () => {
const wRoot = document.querySelector("#editor-container");
let node = null;
try {
const DomEditor = window.wangEditor && window.wangEditor.DomEditor;
if (DomEditor && editor) node = DomEditor.getSelectionNode(editor);
} catch (e) { }
if (!node) {
const sel = window.getSelection();
if (sel && sel.rangeCount) node = sel.getRangeAt(0).commonAncestorContainer;
}
let el = node && node.nodeType === 3 ? node.parentElement : node;
while (el && el !== wRoot && el && el.nodeType === 1) {
if (window.getComputedStyle(el).getPropertyValue("text-align") === "center") {
console.log("居中");
const justifyCenter = toolbarRef.value.querySelector('[data-menu-key="justifyCenter"]');
if (justifyCenter) {
const justifyCenterItem = justifyCenter.parentElement;
justifyCenterItem.classList.add("active");
}
break;
}
el = el.parentElement;
}
};
let loading = ref(false);
const maxSize = 20 * 1024 * 1024; // 20MB
const insertImage = (event) => {
const images = extractImages(editorRef.value);
const count = imageLength - images.length || 0;
if (count == 0) {
creationAlertBox("error", `最多只能上传 ${imageLength} 张图片`);
return;
}
const target = event.target.files[0];
if (!target) return; // 处理未选择文件的情况
if (target.size > maxSize) {
creationAlertBox("error", "文件大小不能超过 20MB");
return;
}
loading.value = true;
uploading(target, target.name, "image").then((data) => {
const selection = window.getSelection();
editorRef.value.focus();
if (lastSelection) {
selection.removeAllRanges();
selection.addRange(lastSelection);
}
const html = `<img src="${data.url}" data-aid="${data.aid}"><br/>`;
document.execCommand("insertHTML", false, html);
judgeIsEmpty();
});
};
const insertVideo = async (event) => {
const videos = extractVideos(editorRef.value);
const count = videoLength - videos.length || 0;
if (count == 0) {
creationAlertBox("error", `最多只能上传 ${videoLength} 个视频`);
return;
}
const videoFile = event.target.files[0];
if (!videoFile) return; // 处理未选择文件的情况
if (videoFile.size > maxSize) {
creationAlertBox("error", "文件大小不能超过 20MB");
return;
}
loading.value = true;
console.log("videoFile", videoFile);
// 步骤1提取视频第一帧等待提取完成
const coverFile = await getVideoFirstFrame(videoFile);
console.log("第一帧提取成功", coverFile);
// 步骤2先上传视频文件type 传 'video',按后端要求调整)
const videoUploadRes = await uploading(videoFile, videoFile.name, "video");
console.log("视频上传成功", videoUploadRes);
// 步骤3再上传第一帧封面type 传 'cover',按后端要求调整)
const coverUploadRes = await uploading(coverFile, coverFile.name, "image");
console.log("封面上传成功", coverUploadRes);
console.log("最终", videoUploadRes, videoUploadRes);
const selection = window.getSelection();
editorRef.value.focus();
if (lastSelection) {
selection.removeAllRanges();
selection.addRange(lastSelection);
}
const html = `<video width="400" height="400" controls preload="none" poster="${coverUploadRes.url}" src="${videoUploadRes.url}" contenteditable="false" posterid="${coverUploadRes.aid}" aid="${videoUploadRes.aid}"></video><br/>`;
document.execCommand("insertHTML", false, html);
judgeIsEmpty();
};
let isEmpty = ref(true);
const onEditorInput = (event) => {
const selection = window.getSelection();
if (selection.rangeCount > 0) {
lastSelection = selection.getRangeAt(0);
// console.log("更新选区");
updatePTitleStatus();
}
judgeIsEmpty();
// debouncedGetTagList();
};
// 防抖函数
const debounce = (fn, delay = 500) => {
let timer = null;
return function () {
if (timer) {
clearTimeout(timer);
}
timer = setTimeout(() => {
fn.apply(this, arguments);
timer = null;
}, delay);
};
};
const getRandomChinese = () => {
// 中文 Unicode 范围:\u4e00 - \u9fa5共约 2 万个汉字)
const start = 0x4e00; // 起始编码
const end = 0x9fa5; // 结束编码
// 生成范围内的随机整数,转为字符
return String.fromCodePoint(Math.floor(Math.random() * (end - start + 1) + start));
};
const generateRandomString = (length = 5) => {
// 定义字符集:包含大小写字母和数字
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
let result = "";
// 循环生成指定长度的随机字符
for (let i = 0; i < length; i++) {
// 从字符集中随机取一个字符
const randomIndex = Math.floor(Math.random() * chars.length);
result += chars[randomIndex];
}
return result;
};
const getTagList = () => {
if (!isLogin.value) {
goLogin();
return;
}
const content = editorRef.value.innerText;
ajax("/v2/api/forum/postPublishTags", {
content,
}).then((res) => {
res = res.data;
if (res.code != 200) return;
let data = res.data || [];
// 随机生成一下数据
for (let i = 0; i < 5; i++) {
data.push({
title: getRandomChinese() + getRandomChinese(),
tagId: generateRandomString(),
});
}
tagList.value = data;
});
};
const debouncedGetTagList = debounce(getTagList, 500);
let isBottomState = ref(false); // 底部按钮 显示
const onEditorFocus = () => {
isBottomState.value = true;
};
const onEditorBlur = () => {
isBottomState.value = false;
};
// 判断是否为空
const judgeIsEmpty = () => {
const text = editorRef.value.innerText;
isEmpty.value = text.length == 0 && !editorRef.value.querySelector("img") && !editorRef.value.querySelector("video");
};
// 处理选中文本变化的函数
const handleSelectionChange = () => {
return;
const selection = window.getSelection();
if (selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
const commonAncestor = range.commonAncestorContainer;
if (editorRef.value.contains(commonAncestor)) {
lastSelection = range;
}
const wRoot = document.querySelector("#editor-container");
if (wRoot && wRoot.contains(commonAncestor)) {
lastSelectionW = range;
updateWHeadingStatus();
}
}
};
let isPTitle = ref(false);
const paragraphTitle = () => {
console.log("editor", editor.addMark);
// editor.addMark("bold", true); // 加粗
// editorRef.value.focus();
// if (!lastSelection) return;
// const selection = window.getSelection();
// selection.removeAllRanges();
// selection.addRange(lastSelection);
// // 使用try-catch确保即使命令执行失败也能恢复滚动位置
// try {
// document.execCommand("formatBlock", false, isPTitle.value ? "P" : "H2");
// } catch (error) {
// console.error("应用段落格式失败:", error);
// }
// // 更新状态
// setTimeout(() => updatePTitleStatus(), 100);
};
const updatePTitleStatus = () => {
if (lastSelection) {
let parentElement = lastSelection.commonAncestorContainer;
// 死循环,直到遇到终止条件
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 cutAnonymity = () => (info.value.anonymous = info.value.anonymous ? 0 : 1);
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 selection = window.getSelection();
editorRef.value.focus();
if (lastSelection) {
selection.removeAllRanges();
selection.addRange(lastSelection);
}
document.execCommand("insertText", false, emoji);
closeEmoji();
judgeIsEmpty();
};
let format = ref("");
const submit = (status) => {
const infoTarget = { ...info.value } || {};
let content = editor.getHtml()
const images = extractImages(editorRef.value);
const videos = extractVideos(editorRef.value);
infoTarget.attachments = infoTarget.attachments || {};
infoTarget.attachments.images = images;
infoTarget.attachments.videos = videos;
info.value["attachments"] = info.value["attachments"] || {};
info.value["attachments"]["images"] = images;
info.value["attachments"]["videos"] = videos;
console.log(content);
content = formatContent(content);
console.log(content);
return;
const data = {
...infoTarget,
content,
};
ajax("/v2/api/forum/postPublishTopic", {
info: data,
token: token.value,
status,
}).then((res) => {
const data = res.data;
if (res.code != 200) {
creationAlertBox("error", res.message);
return;
}
creationAlertBox("success", res.message || "操作成功");
const back = () => {
if (status == 1) redirectToExternalWebsite("/details/" + data.uniqid, "_self");
else redirectToExternalWebsite("/", "_self");
};
setTimeout(() => back(), 1500);
});
};
const formatContent = (html) => {
// 1. 替换图片标签
html = html.replace(/<img[^>]*data-aid="(\d+)"[^>]*>/gi, "[attachimg]$1[/attachimg]");
// 1.1 替换视频标签
html = html.replace(/<video[^>]*aid="(\d+)"[^>]*>[\s\S]*?<\/video>/gi, "[attach]$1[/attach]");
// 2. 替换H2标签
html = html.replace(/<h2[^>]*>([\s\S]*?)<\/h2>/gi, "[b]$1[/b]");
// 5. 处理块级标签换行(仅<div>等块级标签前后换行,保持行内内容连续)
// 块级标签div、p、h1-h6等这里以div为例
html = html.replace(/<\/div>\s*/gi, "</div>\n"); // 闭合div后换行
html = html.replace(/\s*<div[^>]*>/gi, "\n<div>"); // 开启div前换行
// 6. 处理<br>为换行
html = html.replace(/<br\s*\/?>/gi, "\n");
// 7. 移除所有剩余HTML标签 a标签除外
html = html.replace(/<(?!(a\b|\/a\b))[^>]+>/gi, "");
// 8. 清理连续换行(最多保留两个空行,避免过多空行)
// html = html.replace(/\n{3,}/g, "\n\n");
// 去除首尾空白
html = html.trim();
return html;
};
const extractImages = (dom) => {
const images = [];
// 直接查找页面中所有带 data-aid 的 img 标签
const imgElements = dom.querySelectorAll("img");
imgElements.forEach((imgEl) => {
let url = imgEl.getAttribute("src")?.trim() || "";
const urlObj = new URL(url);
const aid = urlObj.searchParams.get('aid');
const queryIndex = url.indexOf('?');
const cleanUrl = queryIndex !== -1 ? url.substring(0, queryIndex) : url;
images.push({
url: cleanUrl,
aid: Number(aid),
});
});
console.log("提取完成的图片列表:", images);
return images;
};
const extractVideos = (dom) => {
// 1. 查找页面中所有 <video> 节点(返回 NodeList 集合)
const videoElements = dom.querySelectorAll("video");
const result = [];
// 2. 遍历每个 video 节点,直接获取属性
videoElements.forEach((videoEl) => {
const posterurl = videoEl.getAttribute("poster")?.trim() || ""; // 视频地址
// 1. 用 URL 构造函数解析链接(自动处理查询参数)
const urlObj = new URL(posterurl);
// 2. 获取 aid 参数get 方法找不到时返回 null
const posterid = urlObj.searchParams.get('aid');
const sourceEl = videoEl.querySelector('source');
const url = sourceEl.getAttribute('src') || null;
const obj = new URL(url);
// 2. 获取 aid 参数get 方法找不到时返回 null
const aid = obj.searchParams.get('aid');
const queryIndex = url.indexOf('?');
const cleanUrl = queryIndex !== -1 ? url.substring(0, queryIndex) : url;
const queryIndex2 = posterurl.indexOf('?');
const cleanPosterurl = queryIndex2 !== -1 ? posterurl.substring(0, queryIndex2) : posterurl;
result.push({
aid: Number(aid),
posterid: Number(posterid),
url: cleanUrl,
posterurl: cleanPosterurl,
});
});
console.log("提取完成的视频列表:", result);
return result;
};
const handleClick = () => {
const selection = window.getSelection();
if (selection.rangeCount > 0) {
lastSelection = selection.getRangeAt(0);
// console.log("更新选区");
updatePTitleStatus();
}
};
let linkUrl = ref("");
let linkText = ref("");
let linkState = ref(false);
const openLink = () => {
console.log("打开链接");
const text = lastSelection ? lastSelection.toString().trim() : "";
console.log("lastSelection", text);
linkText.value = text;
linkState.value = true;
};
const closeLink = () => {
console.log("关闭链接");
linkState.value = false;
linkText.value = "";
linkUrl.value = "";
};
const insertLink = () => {
if (linkText.value == "" || linkUrl.value == "") {
creationAlertBox("error", "请输入链接文字和链接地址");
return;
}
const selection = window.getSelection();
editorRef.value.focus();
if (lastSelection) {
selection.removeAllRanges();
selection.addRange(lastSelection);
}
const html = `<a href="${linkUrl.value}" target="_blank" contenteditable="false">${linkText.value}</a>`;
document.execCommand("insertHTML", false, html);
closeLink();
judgeIsEmpty();
};
const uploading = (target, name, type) => {
return new Promise((resolve, reject) => {
let config = uConfigData;
const formData = new FormData();
formData.append(config.requestName, target); // 文件数据
formData.append("name", name); // 文件名
formData.append("type", type); // 文件名
formData.append("data", config.params.data); // 文件名
ajax(config.url, formData)
.then((res) => {
const data = res.data;
try {
resolve(data);
} catch (error) {
console.error("插入图片出错:", error);
}
})
.finally(() => {
loading.value = false;
});
});
};
// 封装:提取视频第一帧(返回 Promiseresolve 第一帧 Blob
const getVideoFirstFrame = (videoFile) => {
return new Promise((resolve, reject) => {
if (!(videoFile instanceof File) || !videoFile.type.startsWith("video/")) {
reject(new Error("请传入合法的视频文件"));
return;
}
const objectUrl = URL.createObjectURL(videoFile);
const video = document.createElement("video");
video.src = objectUrl;
video.preload = "auto";
video.muted = true;
video.playsInline = true;
const cleanup = () => {
URL.revokeObjectURL(objectUrl);
video.src = "";
};
video.addEventListener(
"error",
() => {
cleanup();
reject(new Error("视频加载失败,请检查文件完整性"));
},
{ once: true }
);
video.addEventListener(
"loadeddata",
() => {
const canvas = document.createElement("canvas");
const w = video.videoWidth || 320;
const h = video.videoHeight || 240;
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext("2d");
try {
ctx.drawImage(video, 0, 0, w, h);
} catch (e) {
cleanup();
reject(e);
return;
}
canvas.toBlob(
(blob) => {
cleanup();
if (!blob) {
reject(new Error("第一帧提取失败Blob 生成异常"));
return;
}
const frameFile = new File([blob], `video_cover_${Date.now()}.png`, { type: "image/png" });
resolve(frameFile);
},
"image/png",
0.9
);
},
{ once: true }
);
});
};
const linkClick = () => { };
const overstriking = () => {
console.log("加粗");
// editor.addMark("bold", true); // 加粗
// editor.addMark("color", "#999"); // 文本颜色
// console.log("editor", editor.addMark);
// editor.addMark('justifyCenter', true) // 加粗
console.log(SlateTransforms.setNodes, editor);
SlateTransforms.setNodes(editor, {
textAlign: 'right'
},)
};
return { toolbarRef, overstriking, isH1, linkClick, insertVideo, insertLink, linkUrl, linkText, linkState, openLink, closeLink, handleClick, uniqid, userInfoWin, titleLength, submit, emojiState, openEmoji, closeEmoji, selectEmoji, optionEmoji, isPTitle, onEditorInput, onEditorFocus, onEditorBlur, paragraphTitle, info, tagList, token, cutAnonymity, editorRef, insertImage, judgeIsEmpty, isEmpty };
},
});
editApp.component("headTop", headTop);
editApp.mount("#edit");