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