From acafc9792a58862fc68878c814355bdf948c2af8 Mon Sep 17 00:00:00 2001 From: "DESKTOP-RQ919RC\\Pc" <1300399510@qq.com> Date: Thu, 25 Dec 2025 17:21:52 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=9B=B4=E6=96=B0CSS=E6=A0=B7=E5=BC=8F?= =?UTF-8?q?=E3=80=81=E6=B7=BB=E5=8A=A0TinyMCE=E6=8F=92=E4=BB=B6=E5=8F=8A?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=8F=91=E5=B8=83=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复移动端登录框样式问题 更新公共JS文件中的授权令牌 添加TinyMCE插件(代码、视觉块、预览等) 优化发布管理页面的编辑器和布局 调整登录组件的响应式样式 --- component/sign-in/sign-in.js | 2 +- component/sign-in/sign-in.txt | 16 +- css/public.css | 2 +- css/public.less | 2 +- css/section.css | 3 + css/section.less | 4 + homepage-other-V2.html | 565 +++++++++++++++ js/public.js | 4 +- js/publish_admin.js | 656 +++++++++++++++--- js/tinymce/icons/default/icons.min.js | 1 + js/tinymce/langs/zh_CN.js | 1 + js/tinymce/models/dom/model.min.js | 4 + js/tinymce/plugins/autolink/plugin.min.js | 4 + js/tinymce/plugins/charmap/plugin.min.js | 4 + js/tinymce/plugins/code/plugin.min.js | 4 + js/tinymce/plugins/codesample/plugin.min.js | 4 + .../plugins/directionality/plugin.min.js | 4 + js/tinymce/plugins/emoticons/js/emojis.min.js | 2 + js/tinymce/plugins/emoticons/plugin.min.js | 4 + js/tinymce/plugins/fullscreen/plugin.min.js | 4 + js/tinymce/plugins/image/plugin.min.js | 4 + js/tinymce/plugins/link/plugin.min.js | 4 + js/tinymce/plugins/lists/plugin.min.js | 4 + js/tinymce/plugins/media/plugin.min.js | 4 + js/tinymce/plugins/preview/plugin.min.js | 4 + .../plugins/searchreplace/plugin.min.js | 4 + js/tinymce/plugins/table/plugin.min.js | 4 + js/tinymce/plugins/template/plugin.min.js | 4 + js/tinymce/plugins/visualblocks/plugin.min.js | 4 + js/tinymce/plugins/visualchars/plugin.min.js | 4 + js/tinymce/plugins/wordcount/plugin.min.js | 4 + .../skins/content/default/content.min.css | 1 + js/tinymce/skins/ui/oxide/content.min.css | 1 + js/tinymce/skins/ui/oxide/skin.min.css | 1 + js/tinymce/themes/silver/theme.min.js | 4 + js/tinymce/tinymce.min.js | 4 + publish_admin.html | 47 +- 37 files changed, 1263 insertions(+), 129 deletions(-) create mode 100644 homepage-other-V2.html create mode 100644 js/tinymce/icons/default/icons.min.js create mode 100644 js/tinymce/langs/zh_CN.js create mode 100644 js/tinymce/models/dom/model.min.js create mode 100644 js/tinymce/plugins/autolink/plugin.min.js create mode 100644 js/tinymce/plugins/charmap/plugin.min.js create mode 100644 js/tinymce/plugins/code/plugin.min.js create mode 100644 js/tinymce/plugins/codesample/plugin.min.js create mode 100644 js/tinymce/plugins/directionality/plugin.min.js create mode 100644 js/tinymce/plugins/emoticons/js/emojis.min.js create mode 100644 js/tinymce/plugins/emoticons/plugin.min.js create mode 100644 js/tinymce/plugins/fullscreen/plugin.min.js create mode 100644 js/tinymce/plugins/image/plugin.min.js create mode 100644 js/tinymce/plugins/link/plugin.min.js create mode 100644 js/tinymce/plugins/lists/plugin.min.js create mode 100644 js/tinymce/plugins/media/plugin.min.js create mode 100644 js/tinymce/plugins/preview/plugin.min.js create mode 100644 js/tinymce/plugins/searchreplace/plugin.min.js create mode 100644 js/tinymce/plugins/table/plugin.min.js create mode 100644 js/tinymce/plugins/template/plugin.min.js create mode 100644 js/tinymce/plugins/visualblocks/plugin.min.js create mode 100644 js/tinymce/plugins/visualchars/plugin.min.js create mode 100644 js/tinymce/plugins/wordcount/plugin.min.js create mode 100644 js/tinymce/skins/content/default/content.min.css create mode 100644 js/tinymce/skins/ui/oxide/content.min.css create mode 100644 js/tinymce/skins/ui/oxide/skin.min.css create mode 100644 js/tinymce/themes/silver/theme.min.js create mode 100644 js/tinymce/tinymce.min.js diff --git a/component/sign-in/sign-in.js b/component/sign-in/sign-in.js index d4b4fc8..9104519 100644 --- a/component/sign-in/sign-in.js +++ b/component/sign-in/sign-in.js @@ -1,5 +1,5 @@ const signTemplate = document.createElement("template"); -signTemplate.innerHTML = `
0
寄托币
签到规则
随机奖励
+{{ reward }}
额外奖励
+{{ extra_reward }}
`; +signTemplate.innerHTML = `
0
寄托币
签到规则
随机奖励
+{{ reward }}
额外奖励
+{{ extra_reward }}
`; class SignInBox extends HTMLElement { static get observedAttributes() { diff --git a/component/sign-in/sign-in.txt b/component/sign-in/sign-in.txt index 4a93a5d..722eeac 100644 --- a/component/sign-in/sign-in.txt +++ b/component/sign-in/sign-in.txt @@ -882,6 +882,18 @@ } } + @media screen and (max-width: 768px) { + .signInBox-mask .signInBox .signInBox-content .sign-in-box .outer-ring .rule-box .rule-header { + padding-top: 20px; + } + .signInBox-mask .signInBox .signInBox-content .sign-in-box .outer-ring .rule-box .rule-list .rule-item .rule-item-icon { + margin-right: 20px; + } + .signInBox-mask .signInBox .signInBox-content .sign-in-box .outer-ring .rule-box .rule-list .rule-item .rule-item-text { + padding: 20px 0; + } + } + @media screen and (max-width: 480px) { .signInBox-mask .signInBox .signInBox-head .header-bi { width: 60px; @@ -959,6 +971,7 @@ .signInBox-mask .signInBox .signInBox-content .sign-in-box .outer-ring .rule-box .rule-header { font-size: 18px; + padding-top: 10px; } .signInBox-mask .signInBox .signInBox-content .sign-in-box .outer-ring .rule-box .rule-list .rule-item .rule-item-icon { @@ -974,7 +987,8 @@ .signInBox-mask .signInBox .signInBox-content .sign-in-box .outer-ring .rule-box .rule-list .rule-item .rule-item-text { font-size: 13px; - padding: 15px 0; + padding: 10px 0; + line-height: 25px; } } diff --git a/css/public.css b/css/public.css index bb48041..febb367 100644 --- a/css/public.css +++ b/css/public.css @@ -1261,7 +1261,7 @@ body { .side-box.essence-side-box .side-header { margin-bottom: 21px !important; } -.side-box.essence-side-box .box .item { +.side-box.essence-side-box .box .item:not(:last-of-type) { margin-bottom: 12px; } .side-box.essence-side-box .box .item .dot { diff --git a/css/public.less b/css/public.less index 0db57d2..8de01a3 100644 --- a/css/public.less +++ b/css/public.less @@ -1526,7 +1526,7 @@ body { margin-bottom: 21px !important; } -.side-box.essence-side-box .box .item { +.side-box.essence-side-box .box .item:not(:last-of-type) { margin-bottom: 12px; } diff --git a/css/section.css b/css/section.css index c3763c6..adf3777 100644 --- a/css/section.css +++ b/css/section.css @@ -388,6 +388,9 @@ .item-box { padding: 18px 20px 0; } + .head-top .sign-in { + display: none !important; + } } @media screen and (max-width: 680px) { #sectionIndex .matter .matter-content .info-box .right .bottom .btn { diff --git a/css/section.less b/css/section.less index e40f25e..561421e 100644 --- a/css/section.less +++ b/css/section.less @@ -463,6 +463,10 @@ .item-box { padding: 18px 20px 0; } + + .head-top .sign-in { + display: none !important; + } } @media screen and (max-width: 680px) { diff --git a/homepage-other-V2.html b/homepage-other-V2.html new file mode 100644 index 0000000..8696bf0 --- /dev/null +++ b/homepage-other-V2.html @@ -0,0 +1,565 @@ + + + + + + + + so猫的个人主页 -- 寄托天下 + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + +
+
+
+
+
+ + +
+
历史搜索
+
+
+
+ +
+ +
+ +
+ +
+ + +
+ + + + +
+
+
+
+
+
+
+
+
IK8gQW_rhIzjn5y_27ky2ZvwRQxRrg7wAsfg1NYIwUkqAJdjHi9EmGZmMjM~
+ + +
+ + 论坛 + +
so猫的个人主页
+
+ +
+
+
+ 用户头像 + +

{{ info.nickname }}

+

+ UID: {{ info.uin }} + +

+
+ +
+

勋章 {{ medallist.length }}

+
+ +
+
+ +
+
发私信
+ +
+
+
+
+ +
+ 用户头像 + {{ info.nickname }} + +
+ + +
+ + +
+ 在线时长 + {{ info.oltime || 0 }} 小时 +
+ + + +
+ 累计签到 + {{ info.sign_count || 0 }} 天 +
+ +
+ 本月签到 + {{ info.sign_month || 0 }} 天 +
+ + +
+ + +
+ +
+ +
+ +
+
+ +
+
+
{{ item.text }}
+
+ +
+ 共 +
{{ count }}
+ 个创作,获得 +
{{ classify == 'all' ? totalLikes : (likeObjValue[classify] || 0) }}
+ 个赞 +
+ +
+ +
+ + +
+ +
- 暂无内容 -
+
+ +
加载更多…
+
+
+
+
+ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/js/public.js b/js/public.js index 6880fbc..d07b3b8 100644 --- a/js/public.js +++ b/js/public.js @@ -24,7 +24,7 @@ const ajax = (url, data) => { url = url.indexOf("https://") > -1 ? url : forumBaseURL + url; if (data) data["v"] = vParam || "v2"; return new Promise(function (resolve, reject) { - if (location.hostname == "127.0.0.1") axios.defaults.headers.common["Authorization"] = "n1pstcsmw6m6bcx49z705xhvduqviw29"; + if (location.hostname == "127.0.0.1") axios.defaults.headers.common["Authorization"] = "d1329afaff3230eae2463306371e74eb"; axios .post(url, data, { @@ -89,7 +89,7 @@ const ajaxGet = (url) => { url = `${url}${paramSymbol}v=${vParam || "v2"}`; return new Promise(function (resolve, reject) { - if (location.hostname == "127.0.0.1") axios.defaults.headers.common["Authorization"] = "n1pstcsmw6m6bcx49z705xhvduqviw29"; + if (location.hostname == "127.0.0.1") axios.defaults.headers.common["Authorization"] = "d1329afaff3230eae2463306371e74eb"; axios .get( diff --git a/js/publish_admin.js b/js/publish_admin.js index 68dfe60..8b47d68 100644 --- a/js/publish_admin.js +++ b/js/publish_admin.js @@ -3,19 +3,15 @@ const { createApp, ref, computed, onMounted, nextTick, onUnmounted } = Vue; const editApp = createApp({ setup() { - const { Editor, FileUploader } = window.textbus; + 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 editor = null; - - const draftKey = "publish_admin_draft"; let uConfigData = {}; - let imageLength = 10; - let videoLength = 5; + const maxSize = 20 * 1024 * 1024; // 1M const formatTime = (d) => { const pad = (n) => String(n).padStart(2, "0"); @@ -27,15 +23,19 @@ const editApp = createApp({ 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; - if (Number(aid)) { - images.push({ - url: cleanUrl, - aid: Number(aid), - }); + 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; @@ -46,19 +46,31 @@ const editApp = createApp({ const result = []; videoElements.forEach((videoEl) => { - const posterurl = videoEl.getAttribute("poster")?.trim() || ""; // 视频地址 - const urlObj = new URL(posterurl); - const posterid = urlObj.searchParams.get("aid"); + 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) {} - const url = sourceEl.getAttribute("src") || null; - const obj = new URL(url); - 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), @@ -75,18 +87,15 @@ const editApp = createApp({ // 提交 const submit = (status) => { const infoTarget = { ...info.value } || {}; - // 获取 HTML 内容 + let content = ""; - if (editor && typeof editor.getHTML === 'function') { - content = editor.getHTML(); - } else if (editor && editor.output) { - content = editor.output.content; // Fallback if getHTML isn't direct + if (window.tinymce && window.tinymce.activeEditor) { + content = window.tinymce.activeEditor.getContent(); } - // 创建临时 DOM 用于提取图片和视频 const tempDiv = document.createElement("div"); tempDiv.innerHTML = content; - + const images = extractImages(tempDiv); const videos = extractVideos(tempDiv); @@ -155,7 +164,8 @@ const editApp = createApp({ if (infoTarget.title) title.value = infoTarget.title; nextTick(() => { - initEditor(); + // Pass content directly to init + initTinyMCE(infoTarget.content || ""); }); }) .catch((err) => { @@ -163,41 +173,37 @@ const editApp = createApp({ }); }; - // 上传图片/视频 获取url const uploading = (file, name, type) => { return new Promise((resolve, reject) => { const upload = () => { let config = uConfigData; const formData = new FormData(); - formData.append(config.requestName, file); // 文件数据 - formData.append("name", name); // 文件名 - formData.append("type", type); // 文件名 + 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; // 允许携带 Cookie - - // 监听上传进度 - xhr.upload.onprogress = function (event) { - if (event.lengthComputable) { - // const percentComplete = (event.loaded / event.total) * 100; - // progress.value = Math.round(percentComplete); - } - }; + xhr.withCredentials = true; xhr.onload = function () { if (xhr.status === 200) { - const res = JSON.parse(xhr.responseText); - if (res.code == 200) { - const data = res.data; - resolve(data); - } else { - creationAlertBox("error", res.message || "上传失败"); - reject(res); + 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", "上传失败"); @@ -225,59 +231,22 @@ const editApp = createApp({ }); }; - // 自定义上传适配器 - class CustomUploader extends FileUploader { - uploadFile(type, file) { - // type 可能是 'image' 或 'video' 等,取决于调用方 - // uploading 函数接受 (file, name, type) - return uploading(file, file.name, type).then(res => { - // 构造带 aid 的 url - return `${res.url}?aid=${res.aid}`; - }); - } - } - - const initEditor = () => { - const editorConfig = { - content: info.value?.content || "", - providers: [{ - provide: FileUploader, - useFactory: () => new CustomUploader() - }], - // 默认情况下,xnote 使用悬浮/气泡菜单 - // 我们不配置 toolbar 容器,让其使用默认行为 - }; - - try { - editor = new Editor(editorConfig); - editor.mount(document.getElementById("editor-text-area")); - - // 监听内容变化 - if (editor.onChange) { - editor.onChange.subscribe(() => { - saveStatus.value = "有未保存的更改"; - }); - } - } catch (error) { - console.log("error", error); - } - - // 点击空白处 focus 编辑器 - document.getElementById("editor-text-area").addEventListener("click", (e) => { - // 如果点击的是容器本身(空白处),则聚焦 - if (e.target.id === "editor-text-area") { - // editor.focus() 如果存在 - // Textbus editor 实例通常不需要手动 focus,除非是 command - } - }); - }; - - // 提取视频第一帧作为封面 - const getVideoFirstFrame = (file) => { - return new Promise((resolve) => { + // 提取视频第一帧作为封面 (支持 File 或 URL) + const getVideoFirstFrame = (source) => { + return new Promise((resolve, reject) => { const video = document.createElement("video"); - video.src = URL.createObjectURL(file); - video.currentTime = 1; // 截取第 1 秒 + 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; }; @@ -291,9 +260,480 @@ const editApp = createApp({ 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