// 简单版本的论坛编辑器,确保图片插入功能正常
const { createApp, ref, computed, onMounted, nextTick, onUnmounted } = Vue;
const editApp = createApp({
setup() {
const LANG = location.href.indexOf("lang=en") > 0 ? "en" : "zh_CN";
const title = ref("");
const saveStatus = ref("");
const uniqid = ref("");
const info = ref({});
const token = ref("");
let uConfigData = {};
const maxSize = 20 * 1024 * 1024; // 1M
const formatTime = (d) => {
const pad = (n) => String(n).padStart(2, "0");
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
};
const extractImages = (dom) => {
const images = [];
const imgElements = dom.querySelectorAll("img");
imgElements.forEach((imgEl) => {
let url = imgEl.getAttribute("src")?.trim() || "";
try {
const urlObj = new URL(url, location.origin);
const aid = urlObj.searchParams.get("aid");
const queryIndex = url.indexOf("?");
const cleanUrl = queryIndex !== -1 ? url.substring(0, queryIndex) : url;
if (Number(aid)) {
images.push({
url: cleanUrl,
aid: Number(aid),
});
}
} catch (e) {
console.error("Error parsing image URL:", url, e);
}
});
return images;
};
const extractVideos = (dom) => {
const videoElements = dom.querySelectorAll("video");
const result = [];
videoElements.forEach((videoEl) => {
const posterurl = videoEl.getAttribute("poster")?.trim() || "";
let posterid = null;
let cleanPosterurl = posterurl;
try {
const urlObj = new URL(posterurl, location.origin);
posterid = urlObj.searchParams.get("aid");
const queryIndex2 = posterurl.indexOf("?");
cleanPosterurl = queryIndex2 !== -1 ? posterurl.substring(0, queryIndex2) : posterurl;
} catch (e) {}
const sourceEl = videoEl.querySelector("source");
if (!sourceEl) return;
const url = sourceEl.getAttribute("src") || "";
let aid = null;
let cleanUrl = url;
try {
const obj = new URL(url, location.origin);
aid = obj.searchParams.get("aid");
const queryIndex = url.indexOf("?");
cleanUrl = queryIndex !== -1 ? url.substring(0, queryIndex) : url;
} catch (e) {}
result.push({
aid: Number(aid),
posterid: Number(posterid),
url: cleanUrl,
posterurl: cleanPosterurl,
});
});
return result;
};
const cutAnonymity = () => (info.value.anonymous = info.value.anonymous ? 0 : 1);
// 提交
const submit = (status) => {
const infoTarget = { ...info.value } || {};
let content = "";
if (window.tinymce && window.tinymce.activeEditor) {
content = window.tinymce.activeEditor.getContent();
}
const tempDiv = document.createElement("div");
tempDiv.innerHTML = content;
const images = extractImages(tempDiv);
const videos = extractVideos(tempDiv);
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;
infoTarget.title = title.value;
const data = {
...infoTarget,
content,
};
ajax("/v2/api/forum/postPublishTopic", {
info: data,
token: token.value,
status,
htmledit: 1,
}).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 cUpload = () => {
ajaxGet(`/v2/api/config/upload?type=topic`).then((res) => {
const data = res.data;
uConfigData = data;
});
};
const init = () => {
ajax("/v2/api/forum/postPublishInit", {
uniqid: uniqid.value,
htmledit: 1,
})
.then((res) => {
const data = res.data;
if (res.code != 200) {
creationAlertBox("error", res.message || "操作失败");
return;
}
const infoTarget = data.info || {};
info.value = infoTarget;
token.value = data.token;
if (infoTarget.title) title.value = infoTarget.title;
nextTick(() => {
// Pass content directly to init
initTinyMCE(infoTarget.content || "");
});
})
.catch((err) => {
console.log("err", err);
});
};
const uploading = (file, name, type) => {
return new Promise((resolve, reject) => {
const upload = () => {
let config = uConfigData;
const formData = new FormData();
formData.append(config.requestName || "file", file);
formData.append("name", name);
formData.append("type", type);
if (config.params && config.params.data) {
formData.append("data", config.params.data);
}
const xhr = new XMLHttpRequest();
xhr.open("POST", config.url, true);
xhr.withCredentials = true;
xhr.onload = function () {
if (xhr.status === 200) {
try {
const res = JSON.parse(xhr.responseText);
if (res.code == 200) {
const data = res.data;
resolve(data);
} else {
creationAlertBox("error", res.message || "上传失败");
reject(res);
}
} catch (e) {
creationAlertBox("error", "解析响应失败");
reject(e);
}
} else {
creationAlertBox("error", "上传失败");
reject(new Error("Upload failed"));
}
};
xhr.onerror = function () {
creationAlertBox("error", "网络错误,上传失败");
reject(new Error("Network error"));
};
xhr.send(formData);
};
if (!uConfigData || !uConfigData.url) {
ajaxGet(`/v2/api/config/upload?type=topic`).then((res) => {
const data = res.data;
uConfigData = data;
upload();
});
} else {
upload();
}
});
};
// 提取视频第一帧作为封面 (支持 File 或 URL)
const getVideoFirstFrame = (source) => {
return new Promise((resolve, reject) => {
const video = document.createElement("video");
video.setAttribute("crossOrigin", "anonymous"); // Allow cross-origin for cover generation
if (source instanceof File) {
video.src = URL.createObjectURL(source);
} else if (typeof source === "string") {
video.src = source;
} else {
reject(new Error("Invalid source for video cover generation"));
return;
}
video.currentTime = 1;
video.onloadeddata = () => {
video.currentTime = 1;
};
video.onseeked = () => {
const canvas = document.createElement("canvas");
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
canvas.getContext("2d").drawImage(video, 0, 0, canvas.width, canvas.height);
canvas.toBlob((blob) => {
const coverFile = new File([blob], "cover.jpg", { type: "image/jpeg" });
resolve(coverFile);
}, "image/jpeg");
};
video.onerror = (e) => {
reject(e);
};
});
};
const initTinyMCE = (initialContent) => {
if (window.tinymce.get("editor-text-area")) {
window.tinymce.get("editor-text-area").remove();
}
// Calculate height based on window size to match original CSS calc(100vh - 370px)
// const editorHeight = window.innerHeight - 330;
const editorConfig = {
selector: "#editor-text-area",
language: LANG === "en" ? "en" : "zh_CN",
language_url: LANG === "en" ? undefined : "/js/tinymce/langs/zh_CN.js",
plugins: "image media table link lists code charmap emoticons wordcount fullscreen preview searchreplace autolink directionality visualblocks visualchars template codesample",
paste_preprocess: (plugin, args) => {
if (!args.content.match(/<\/?(p|div|h[1-6]|ul|ol|table|blockquote|pre)[^>]*>/i)) {
const fragments = args.content.split(/
/gi);
const newContent = fragments
.map((frag) => {
// 移除 和空白后检查是否有内容
// 保留图片等 HTML 标签,只过滤纯空白行
if (frag.replace(/ /gi, "").trim().length > 0) {
return "
" + frag + "
"; } return null; }) .filter(Boolean) .join(""); if (newContent) { args.content = newContent; } } }, toolbar: "undo redo | blocks | bold italic underline strikethrough | alignleft aligncenter alignright alignjustify | bullist numlist outdent indent | link image media | removeformat | emoticons | fullscreen", menubar: false, fixed_toolbar_container: "#editor-toolbar", // height: editorHeight > 300 ? editorHeight : 300, // Ensure minimum height // resize: false, height: "100%", // Use CSS height resize: true, // Allow user to resize if needed, or rely on container branding: false, promotion: false, convert_urls: false, media_live_embeds: true, // Enable live video previews initialValue: initialContent, // Set initial content here // Add urlconverter_callback to handle network resources urlconverter_callback: (url, node, on_save, name) => { // Return URL as is, let image upload handler process it if needed return url; }, // Intercept Media Dialog URL input media_url_resolver: function (data, resolve /*, reject*/) { const url = data.url; // Only intercept http/https URLs that are NOT from our domain (already uploaded) if (url && (url.startsWith("http://") || url.startsWith("https://")) && !url.includes("?aid=")) { // Check if it's a video file type we care about const isVideo = /\.(mp4|webm|ogg|mov|mkv|avi|flv|wmv)$/i.test(url); if (isVideo && uConfigData && uConfigData.url) { creationAlertBox("info", "Uploading network video..."); const formData = new FormData(); formData.append("uploadType", "url"); formData.append("url", url); if (uConfigData.params && uConfigData.params.data) { formData.append("data", uConfigData.params.data); } ajax(uConfigData.url, formData) .then(async (res) => { if (res.code == 200 && res.data) { const newUrl = `${res.data.url}?aid=${res.data.aid}`; try { // Generate cover const coverFile = await getVideoFirstFrame(newUrl); const coverRes = await uploading(coverFile, "cover.jpg", "image"); const coverUrl = `${coverRes.url}?aid=${coverRes.aid}`; // Return the full HTML embed code // TinyMCE expects HTML when using resolve() for embeds const videoHtml = ``; resolve({ html: videoHtml }); // Mark as processed (need to wait for insertion) setTimeout(() => { const editor = window.tinymce.activeEditor; if (editor) { const insertedVideo = editor.dom.select(`video[src="${newUrl}"]`)[0]; if (insertedVideo) { // We need to access processedNodes from the outer scope if possible, // but this config object is defined inside initTinyMCE. // However, processedNodes is defined in setup(). // We can't access it here easily unless we move processedNodes to higher scope or use a global/editor property. // Let's attach it to the editor instance in setup(). if (editor.processedNodes) { editor.processedNodes.add(insertedVideo); } } } }, 500); creationAlertBox("success", "Video uploaded successfully"); } catch (e) { console.error("Cover generation failed", e); const videoHtml = ``; resolve({ html: videoHtml }); creationAlertBox("success", "Video uploaded (cover failed)"); } } else { creationAlertBox("error", "Video upload failed"); resolve({ html: `Upload failed: ${url}` }); // Or just resolve empty to cancel? } }) .catch((err) => { console.error("Media resolver upload failed", err); resolve({ html: "" }); // Or fallback to original url? resolve({ html: `` }) }); // Return early, we will resolve asynchronously return; } } // Default behavior for other URLs or if logic skipped resolve({ html: "" }); // Letting it empty might trigger default embed logic? // Actually, if we return empty string, TinyMCE might just insert nothing or error. // The default behavior is to rely on promises. // If we want default behavior, we should probably NOT define this option or call a fallback? // Wait, media_url_resolver replaces the default logic. // If we don't handle it, we must return a promise that resolves to HTML. // If we want the default behavior (creating a video tag for the URL), we have to do it ourselves. const defaultHtml = ``; resolve({ html: defaultHtml }); }, // Handle pasted/dropped images images_upload_handler: (blobInfo, progress) => { return new Promise((resolve, reject) => { if (blobInfo.blob().size > maxSize) { reject({ message: "图片大小不能超过 20MB", remove: true }); return; } uploading(blobInfo.blob(), blobInfo.filename(), "image") .then((res) => { resolve(res.url + "?aid=" + res.aid); }) .catch((err) => { reject({ message: err.message || "Image upload failed", remove: true }); }); }); }, file_picker_callback: (callback, value, meta) => { const input = document.createElement("input"); input.setAttribute("type", "file"); if (meta.filetype === "image") { input.setAttribute("accept", "image/png, image/jpeg, image/jpg"); } else if (meta.filetype === "media") { input.setAttribute("accept", "video/flv, video/mkv, video/avi, video/rm, video/rmvb, video/mpeg, video/mpg, video/ogg, video/ogv, video/mov, video/wmv, video/mp4, video/webm, video/m4v"); } input.addEventListener("change", (e) => { const file = e.target.files[0]; if (!file) return; if (file.size > maxSize) { creationAlertBox("error", "文件大小不能超过 20MB"); return; } if (meta.filetype === "image") { uploading(file, file.name, "image") .then((res) => { callback(res.url + "?aid=" + res.aid, { alt: file.name }); }) .catch((err) => { console.error(err); creationAlertBox("error", "Image upload failed"); }); } else if (meta.filetype === "media") { creationAlertBox("info", "Uploading video, please wait..."); uploading(file, file.name, "video") .then(async (videoRes) => { try { const coverFile = await getVideoFirstFrame(file); const coverRes = await uploading(coverFile, coverFile.name, "image"); callback(videoRes.url + "?aid=" + videoRes.aid, { poster: coverRes.url + "?aid=" + coverRes.aid }); creationAlertBox("success", "Video uploaded successfully"); } catch (e) { console.error(e); callback(videoRes.url + "?aid=" + videoRes.aid); creationAlertBox("success", "Video uploaded (cover generation failed)"); } }) .catch((err) => { console.error(err); creationAlertBox("error", "Video upload failed"); }); } }); input.click(); }, setup: (editor) => { const processedNodes = new WeakSet(); editor.processedNodes = processedNodes; // Expose to editor instance for media resolver access editor.on("change keyup", () => { saveStatus.value = "有未保存的更改"; }); // Also try to set content on init as fallback/confirmation editor.on("init", () => { // Only if empty (though initialValue should handle it) if (!editor.getContent() && initialContent) { editor.setContent(initialContent); } // Mark all existing images/videos as processed to prevent auto-upload on click // This handles the case where initial content contains external images that are already "uploaded" // or should be treated as such (not auto-uploaded again). const body = editor.getBody(); const imgs = body.querySelectorAll("img"); const videos = body.querySelectorAll("video"); imgs.forEach((node) => processedNodes.add(node)); videos.forEach((node) => processedNodes.add(node)); }); // Handle network images/videos paste or drop editor.on("Paste", async (e) => { const clipboardData = e.clipboardData || window.clipboardData; const html = clipboardData.getData("text/html"); if (html) { const div = document.createElement("div"); div.innerHTML = html; const images = div.querySelectorAll("img"); const videos = div.querySelectorAll("video"); if (images.length > 0) { for (let img of images) { const src = img.getAttribute("src"); if (src && !src.includes("?aid=") && src.startsWith("http") && uConfigData && uConfigData.url) { try { const formData = new FormData(); formData.append("uploadType", "url"); formData.append("url", src); if (uConfigData.params && uConfigData.params.data) { formData.append("data", uConfigData.params.data); } const res = await ajax(uConfigData.url, formData); if (res.code == 200 && res.data) { const newUrl = `${res.data.url}?aid=${res.data.aid}`; setTimeout(() => { const editorContent = editor.getContent(); if (editorContent.includes(src)) { const newContent = editorContent.replace(src, newUrl); editor.setContent(newContent); } }, 100); } } catch (err) { console.error("Failed to upload network image:", src, err); } } } } if (videos.length > 0) { for (let video of videos) { const src = video.getAttribute("src"); if (src && !src.includes("?aid=") && src.startsWith("http") && uConfigData && uConfigData.url) { try { creationAlertBox("info", "Uploading network video..."); const formData = new FormData(); formData.append("uploadType", "url"); formData.append("url", src); if (uConfigData.params && uConfigData.params.data) { formData.append("data", uConfigData.params.data); } const res = await ajax(uConfigData.url, formData); if (res.code == 200 && res.data) { const newUrl = `${res.data.url}?aid=${res.data.aid}`; // Generate cover from the new local URL try { const coverFile = await getVideoFirstFrame(newUrl); const coverRes = await uploading(coverFile, "cover.jpg", "image"); const coverUrl = `${coverRes.url}?aid=${coverRes.aid}`; setTimeout(() => { const editorContent = editor.getContent(); // Replace src and add poster // This regex might need to be more robust if (editorContent.includes(src)) { let newContent = editorContent.replace(src, newUrl); // Find the video tag and inject poster if not present, or replace // Simple string replace for src is safe, but for poster we need DOM manipulation or regex // Let's use DOM for precision const tempDiv = document.createElement("div"); tempDiv.innerHTML = newContent; const tempVideo = tempDiv.querySelector(`video[src*="${res.data.url}"]`); // Use partial match or ID if (tempVideo) { tempVideo.setAttribute("poster", coverUrl); editor.setContent(tempDiv.innerHTML); } else { // Fallback: regex replace the tag // This is tricky without unique ID. // Let's just setContent with simple replace first, then update poster via NodeChange logic or DOM editor.setContent(newContent); // We can trigger a NodeChange or find it again } } }, 100); } catch (e) { console.error("Cover generation failed", e); // Update video without cover setTimeout(() => { const editorContent = editor.getContent(); if (editorContent.includes(src)) { const newContent = editorContent.replace(src, newUrl); editor.setContent(newContent); } }, 100); } } } catch (err) { console.error("Failed to upload network video:", src, err); } } } } } }); // Listen for NodeChange to catch inserted images/videos that might be external editor.on("NodeChange", async (e) => { const node = e.element; if (!uConfigData || !uConfigData.url) return; // Handle Image if (node.tagName === "IMG" && !node.getAttribute("data-mce-object")) { // Check if already processed if (processedNodes.has(node)) return; const src = node.getAttribute("src"); // Check local or invalid if (!src || src.startsWith("data:") || src.includes("?aid=") || src.startsWith(location.origin) || src.startsWith("/")) { processedNodes.add(node); return; } if (src.startsWith("http")) { // Mark as processed processedNodes.add(node); try { const formData = new FormData(); formData.append("uploadType", "url"); formData.append("url", src); if (uConfigData.params && uConfigData.params.data) { formData.append("data", uConfigData.params.data); } const res = await ajax(uConfigData.url, formData); if (res.code == 200 && res.data) { const newUrl = `${res.data.url}?aid=${res.data.aid}`; editor.dom.setAttrib(node, "src", newUrl); editor.dom.setAttrib(node, "data-mce-src", newUrl); // Remove from processedNodes? No, because now src has ?aid= so it will be ignored anyway. // And if we undo, node is new. } else { console.warn("Upload failed for", src); } } catch (err) { console.error("Failed to upload network image on NodeChange:", src, err); // Do NOT remove from processedNodes to avoid retry loop on persistent error } } } // Handle Video // TinyMCE 6 with media_live_embeds: true uses