// 简单版本的论坛编辑器,确保图片插入功能正常 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", 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