上传文件
遇到问题?请按照图片上传故障排查指南排查请求缺失、413 错误以及保存后图片丢失等问题。
上传处理程序由您来编写。它接收用户插入、粘贴或拖放的文件,把文件发送到您的服务器,然后把要放入文档的 URL 交还给编辑器。下载包中的示例包含适用于 ASP.NET Web Forms、MVC、ASP.NET Core 和 PHP 的可用处理程序,以及下面的客户端脚本。
请配置处理程序,否则文件会被嵌入内容中
如果没有配置处理程序,什么都不会被上传,而文件总得有个去处,于是它会进入文档:
- 图片会以 base64
data:URL 的形式嵌入。图片会成为您保存的 HTML 的一部分,而 base64 会增加大约三分之一的体积:一张 3 MB 的照片会在一个字段中变成约 4 MB 的文本。这是写入数据库时保存失败、被截断或超时的常见原因。 - 文档会以带有文件名的链接形式插入,但没有可用的地址:编辑器让它指向一个临时的
blob:URL,而内容净化程序会把它删除,因为blob:不在它允许的协议之列。读者得到的是一个无法打开的链接。设置fileUploadLocalObjectUrl: false,即可拒绝插入并告知用户。
有了处理程序,内容中保存的就是一个简短的 URL,例如 /imageuploads/20260918-a1b2c3.png。要检查现有文档,请查看您存储的 HTML:出现 src="data:image/... 说明没有使用处理程序。
Insert Gallery 对话框的 Upload 按钮只会查找全局的 window.rte_file_upload_handler。如果只设置了针对单个编辑器的 file_upload_handler 选项,图片库就不会显示 Upload 按钮。
简便方式:给编辑器一个 URL
如果您的服务器已经能够接收文件并返回其 URL,就完全不需要编写任何 JavaScript:
var config = {};
// The whole setup: the editor POSTs the file here and uses the URL you answer with.
config.file_upload_url = "/imageupload.ashx";
var editor = new RichTextEditor("#div_editor", config);编辑器以 multipart/form-data 格式 POST 文件,并从响应中读取 URL。它能理解其他编辑器的端点已经在使用的响应格式,因此为 TinyMCE 或 CKEditor 编写的服务器无需修改即可在这里使用:
| 响应体 | 适用于 |
|---|---|
{"location": "/img/x.png"} | TinyMCE |
{"url": "/img/x.png"} | CKEditor 简单上传适配器 |
{"urls": {"default": "..."}} | CKEditor,多种尺寸 |
READY:/img/x.png | 下面的示例处理程序 |
{"error": {"message": "..."}} 或 ERROR:message | 两者皆可,报告为失败 |
不符合上述任何格式的响应都会被视为上传失败,而不会被插入,因此返回 OK 的端点不可能成为图片的来源。
config.file_upload_field = "file"; // form field name; CKEditor's endpoints use "upload"
config.file_upload_base_path = "https://cdn.example.com"; // prepended to a relative URL
config.file_upload_credentials = true; // send cookies on a cross-origin upload
config.file_upload_reuse_filename = true; // keep the original name (default: a unique one)
config.file_upload_headers = { "X-CSRF-Token": token };默认情况下,每个上传的文件都会获得一个唯一的名称,因为两个人向同一端点上传 image.png 时,不能互相覆盖。重试、取消和进度事件的工作方式与使用您自己的处理程序时相同。当您需要在发送前缩放图片、直接上传到 S3 或显示自己的对话框时,请改为编写处理程序——两者都设置时,处理程序优先。
语法
// One global handler for every editor on the page.
// The Insert Gallery dialog's Upload button uses this one.
window.rte_file_upload_handler = function (file, callback, optionalIndex, optionalFiles) { ... };
// Or per editor, for the image dialog, paste and drag-and-drop.
var config = {};
config.file_upload_handler = function (file, callback, optionalIndex, optionalFiles) { ... };
var editor = new RichTextEditor("#div_editor", config);file
Type: File——浏览器的 File 对象,带有 name、size 和 type。用 XMLHttpRequest 或 fetch 发送它。
callback
Type: Function callback(url, errorMessage)——必须恰好调用一次。成功时传入保存后的 URL;失败时传入 null 和一条消息,编辑器会把消息显示给用户。被用户取消的上传也算失败。
optionalIndex、optionalFiles
一次插入多个文件时,会为每个文件调用一次处理程序:optionalFiles 是整组文件,optionalIndex 是当前文件的位置,因此可以把进度报告为“第 2 个,共 5 个”。
示例处理程序以纯文本作答:状态码 200 加 READY:<url>,或状态码 500 加 ERROR:<message>。下面的客户端脚本会把它们转换为两种回调形式。
取消上传
从处理程序返回一个带有 abort() 方法的对象(或者一个单独的中止函数),编辑器就能停止仍在进行中的请求:
config.file_upload_handler = function (file, callback) {
var xhr = new XMLHttpRequest();
xhr.open("POST", "/imageupload.ashx");
// ... send the file, then callback(url) or callback(null, message)
xhr.send(formdata);
// Returning this is what lets the editor stop the request.
return { abort: function () { xhr.abort(); } };
};
editor.cancelUploads(); // abort everything in flight; returns the count
editor.getPendingUploadCount(); // how many are still running当处理程序提供了中止函数时,cancelUploads() 和 destroy() 会中止进行中的请求。如果目标图片已被删除,迟到的结果会被丢弃;删除图片本身并不能保证立即中止网络请求。被取消的上传的迟到回调会被忽略,因此缓慢的响应不会把已删除的图片放回去。会触发一个 uploadcancel 事件,并带有文件和原因。什么都不返回也可以:只是上传无法被中止。
内容中已经嵌入的图片
处理程序只会为用户插入、粘贴或拖放的文件调用。通过其他途径进入的内容——用 setHTMLCode 设置的、从网页或其他编辑器粘贴的、或从草稿恢复的——会保留其 base64 data: URL,因此这些图片会被保存在 HTML 内部,您的处理程序根本看不到它们。请在保存之前调用 uploadEmbeddedImages:
对于原生表单,请使用下面的 ID,并把 action 替换为您的文档保存端点。请在创建编辑器之前配置上传处理程序。这个示例会等待上传完成,拒绝尚未解决的本地图片,并保留原生验证和提交按钮。
<form id="article-form" action="/your-document-save-endpoint" method="post">
<!-- Include your framework's CSRF token field here. -->
<div id="div_editor"></div>
<input id="content" name="content" type="hidden">
<p id="upload-save-status" role="status" aria-live="polite"></p>
<button type="submit">Save document</button>
</form>var form = document.getElementById("article-form");
var content = document.getElementById("content");
var saveStatus = document.getElementById("upload-save-status");
var scanning = false, approvedHTML = null;
form.addEventListener("submit", function (event) {
var current = editor.getHTMLCode();
if (approvedHTML !== null) {
var approved = approvedHTML;
approvedHTML = null;
if (current !== approved || editor.getPendingUploadCount() > 0) {
event.preventDefault();
saveStatus.textContent = "Content changed. Wait for uploads, then save again.";
return;
}
content.value = current; // Fresh snapshot in the native submit event.
return;
}
event.preventDefault();
if (scanning || editor.getPendingUploadCount() > 0) {
saveStatus.textContent = "Images are still uploading. Wait, then save again.";
return;
}
scanning = true;
var submitter = event.submitter;
saveStatus.textContent = "Checking embedded images...";
try {
editor.uploadEmbeddedImages(function (summary) {
scanning = false;
var localImage = Array.prototype.some.call(
editor.getDocument().querySelectorAll("img"), function (image) {
return /^(data:|blob:|file:)/i.test((image.getAttribute("src") || "").trim());
});
if (summary.failed || summary.skipped || localImage || editor.getPendingUploadCount() > 0) {
saveStatus.textContent = "Some images are not ready. Review and retry or remove them, then save again.";
return;
}
approvedHTML = editor.getHTMLCode();
saveStatus.textContent = "Images ready. Complete any required fields before saving.";
// Re-run native validation; preserve the initiating submit button.
if (submitter && submitter.form === form) form.requestSubmit(submitter);
else form.requestSubmit();
approvedHTML = null; // Validation may have stopped the submit event.
});
} catch (error) {
scanning = false;
approvedHTML = null;
saveStatus.textContent = "Could not prepare images. Check your upload handler and try again.";
}
});它会遍历当前文档,把每个 data:image/ 源转换为 File,逐个通过处理程序发送,并把每个源替换为您返回的 URL。已经位于 http(s) 上的图片保持不变。没有配置处理程序时,什么都不会改变,每张图片都计为已跳过。
待处理数量为零并不能证明成功:上传失败或被取消之后,请检查缺失的图片。不要直接调用 form.submit();它会绕过原生验证和提交监听器。上传图片与保存文档是两回事;您的服务器在存储或渲染提交的 HTML 之前,必须对其进行身份验证、授权和净化。
自动上传与失败重试
automaticUploads 会替您安排上面的扫描。它并不能保证保存已完成:在获取保存快照之前,请保留待处理/错误检查,并检查尚未解决的嵌入图片:
var config = {};
config.file_upload_handler = rte_file_upload_handler;
// Upload embedded base64 images by themselves, after setHTMLCode and after a paste.
config.automaticUploads = true;
config.automaticUploadsDelay = 300; // ms debounce; a paste of several images uploads once
// Retry a failed upload before telling the user about it.
config.uploadRetryCount = 2; // extra attempts after the first (default 0)
config.uploadRetryDelay = 1000; // ms before the first retry; doubles after that它在 setHTMLCode 之后以及插入或粘贴 HTML 之后运行,经过防抖处理,没有配置处理程序时什么也不做。uploadRetryCount 解决的是另一种常见故障:以前一次连接中断就会让图片永远保持嵌入状态。重试成功时不会有任何提示,每次尝试都会触发一个 uploadretry 事件,所有尝试都用完之后,会像以前一样报告回调中的错误。
限制上传文件和内容的大小
设置 maxUploadFileSize 后,编辑器会在读取之前就自行拒绝更大的文件。不会嵌入任何内容,也不会发送任何内容,并会告知用户被拒绝的文件大小以及限制是多少:
var config = {};
// Largest file the editor will insert or upload, in bytes. 0 (the default) = no limit.
config.maxUploadFileSize = 5 * 1024 * 1024; // 5 MB
// Optional: replace the default alert with your own dialog.
// The handler receives (state, name, message); set state.returnValue = true to say
// you have handled it, and the editor shows nothing itself.
config.oncustomdialog = function (state, name, message) {
if (name !== "uploadtoobig") return; // leave the others to the editor
showMyDialog(message);
state.returnValue = true;
};它覆盖了文件进入的所有途径:图片对话框、文档对话框、粘贴和拖放。已经嵌入在粘贴的 HTML(例如来自 Word)中的图片不在覆盖范围内,因为在粘贴过程中拒绝其中一张会让文档只转换了一半;下面的 maxHTMLLength 是针对这种情况的最后防线。
请同样在处理程序中保留这项检查,因为浏览器不是一个值得信赖的执行限制的地方。示例处理程序会拒绝任何超过 4,000,000 字节的文件,并返回 ERROR:file too big。
另外两项服务器限制也必须大于您最大的文件:
- ASP.NET:
<httpRuntime>中的maxRequestLength(单位为千字节,默认 4096)和<requestLimits>中的maxAllowedContentLength(单位为字节,默认约 30 MB)。 - PHP:
upload_max_filesize(默认 2 MB)和post_max_size。如果文件超过这些限制,PHP 会在您的处理程序运行之前丢弃文件,编辑器会报告没有收到文件。
要限制文档本身的大小,请设置字符数限制。两个选项默认都是关闭的(0);当一次编辑超过限制时,编辑器会恢复到上一个有效状态并显示一条消息,而不是保存一份会被数据库拒绝的内容:
var config = {};
config.maxHTMLLength = 60000; // characters INCLUDING the HTML tags; 0 = no limit
config.maxTextLength = 0; // characters EXCLUDING the tags; 0 = no limit请把 maxHTMLLength 设置为略小于存储 HTML 的数据库列的大小。处理 reachmaxlength 钩子,即可用您自己的对话框替换默认的警告。
上传之前缩小照片
手机相机生成的文件远远大于任何网页的需要,而用 maxUploadFileSize 拒绝它们,只会让作者去另找一张图片。编辑器本身没有缩放设置;缩放应当放在处理程序中,因为只有您的应用知道保存的副本应该多大。请在 canvas 上缩放图片,然后上传结果,而不是原图:
config.file_upload_handler = function (file, callback, optionalIndex, optionalFiles) {
var MAXWIDTH = 1600, MAXHEIGHT = 1600, QUALITY = 0.82;
function send(blob, name) {
// your own upload code: post "blob" under the name "name",
// then callback(theUrl) or callback(null, theError).
}
// Leave anything that is not a still photograph alone. GIFs would lose
// their animation, and a PDF is not something a canvas can redraw.
if (file.type.indexOf("image/") !== 0 || file.type === "image/gif") {
send(file, file.name);
return;
}
var url = URL.createObjectURL(file);
var img = new Image();
img.onload = function () {
var scale = Math.min(1, MAXWIDTH / img.width, MAXHEIGHT / img.height);
if (scale === 1) { URL.revokeObjectURL(url); send(file, file.name); return; } // already small enough
var canvas = document.createElement("canvas");
canvas.width = Math.round(img.width * scale);
canvas.height = Math.round(img.height * scale);
canvas.getContext("2d").drawImage(img, 0, 0, canvas.width, canvas.height);
URL.revokeObjectURL(url);
canvas.toBlob(function (blob) {
if (!blob) { send(file, file.name); return; } // the browser refused: send the original
send(blob, file.name.replace(/\.[^.]+$/, "") + ".jpg");
}, "image/jpeg", QUALITY);
};
img.onerror = function () { URL.revokeObjectURL(url); callback(null, "not-an-image"); };
img.src = url;
};以一张 2400 × 1600 的照片实测:3.9 MB 的原图被上传为一张 1600 × 1067、94 KB 的 JPEG,文档中保存的是您的服务器返回的 URL。
有两点需要注意。转换为 JPEG 会丢弃透明度,因此如果用户除了照片还会粘贴徽标,请根据 file.type 分别处理。另外,重新编码会去除文件的元数据——这通常是好事,因为照片的 GPS 坐标就保存在那里,但如果您依赖这些元数据,就不是了。
上传失败时
以 null 和一条消息调用回调:callback(null, "http-error-413")。编辑器会显示 The file could not be uploaded.(文件无法上传),并在括号中附上您的消息,而 uploadfailed 钩子会先触发,因此您可以改为显示自己的对话框。该钩子收到的消息与您传入的完全相同——是原始代码,而不是包裹它的句子——因此您可以据此进行分支处理。
当已配置的上传处理程序报告失败时,当前发布的核心会移除图片,而不是保留其 base64 预览。请把 maxUploadFileSize 设置为端点实际执行的限制,让文件在读取之前就被拒绝。
JavaScript 代码模板
这段客户端脚本改编自下载包中可用的处理程序,它会显示一个带有 Cancel 按钮的进度对话框,POST 文件,并以 URL 或错误调用回调。它只会结束一次,60 秒后超时,支持用键盘取消,并会恢复焦点。单页面的 rte_upload_state 计数器属于这个示例,而不是编辑器 API;在包含多个编辑器的应用中,请为每个编辑器单独隔离它。
var uploadhandlerpath = "/imageupload.ashx";
function rte_file_upload_handler(file, callback, optionalIndex, optionalFiles) {
var originalCallback = callback;
var finished = false;
rte_upload_state.pending++;
callback = function (url, error) {
if (finished) return;
finished = true;
if (error) rte_upload_state.error = String(error);
try { originalCallback(url, error); }
finally { rte_upload_state.pending--; }
};
try {
function append(parent, tagname, csstext) {
var tag = parent.ownerDocument.createElement(tagname);
if (csstext) tag.style.cssText = csstext;
parent.appendChild(tag);
return tag;
}
var uploadcancelled = false;
var dialogouter = append(document.body, "div", "display:flex;align-items:center;justify-content:center;z-index:2147483646;position:fixed;inset:0;padding:16px;box-sizing:border-box;background-color:rgba(15,23,42,0.28)");
var dialoginner = append(dialogouter, "div", "box-sizing:border-box;min-width:260px;max-width:calc(100vw - 32px);padding:16px;border:1px solid #cbd5e1;border-radius:8px;background:#fff;box-shadow:0 16px 40px rgba(15,23,42,.2)");
var line1 = append(dialoginner, "div", "margin:0 0 10px;color:#172033;font:700 15px/1.3 Aptos,Segoe UI,sans-serif;");
var previousFocus = document.activeElement;
window.__rteDemoUploadDialogId = (window.__rteDemoUploadDialogId || 0) + 1;
line1.id = "rte-upload-label-" + window.__rteDemoUploadDialogId;
dialogouter.setAttribute("role", "dialog");
dialogouter.setAttribute("aria-modal", "true");
dialogouter.setAttribute("aria-labelledby", line1.id);
line1.innerText = "Uploading...";
var totalsize = file.size;
var sentsize = 0;
if (optionalFiles && optionalFiles.length > 1) {
totalsize = 0;
for (var i = 0; i < optionalFiles.length; i++) {
totalsize += optionalFiles[i].size;
if (i < optionalIndex) sentsize = totalsize;
}
line1.innerText = "Uploading..." + (optionalIndex + 1) + "/" + optionalFiles.length;
}
var line2 = append(dialoginner, "div", "margin:0 0 8px;color:#52657e;font:600 12px/1.3 Aptos,Segoe UI,sans-serif;");
line2.innerText = "0%";
var progressbar = append(dialoginner, "div", "height:8px;overflow:hidden;border-radius:4px;background:#e7eef7;");
progressbar.setAttribute("role", "progressbar");
progressbar.setAttribute("aria-label", "File upload progress");
progressbar.setAttribute("aria-valuemin", "0");
progressbar.setAttribute("aria-valuemax", "100");
progressbar.setAttribute("aria-valuenow", "0");
var progressbg = append(progressbar, "div", "width:0;height:100%;border-radius:inherit;background:#2487e8;transition:width 120ms ease;");
var line3 = append(dialoginner, "div", "display:flex;justify-content:flex-end;margin-top:14px;");
var btn = append(line3, "button");
btn.style.cssText = "min-height:32px;padding:7px 11px;border:1px solid #bdd0e6;border-radius:7px;background:#fff;color:#315277;font:700 12px/1 Aptos,Segoe UI,sans-serif;cursor:pointer;";
btn.type = "button";
btn.setAttribute("aria-label", "Cancel file upload");
dialoginner.addEventListener("keydown", function (event) {
if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); btn.click(); }
else if (event.key === "Tab" && !event.ctrlKey && !event.metaKey && !event.altKey) { event.preventDefault(); btn.focus(); }
});
btn.focus();
btn.innerText = "Cancel";
btn.onclick = function () {
uploadcancelled = true;
xh.abort();
}
var xh = new XMLHttpRequest();
xh.timeout = 60000;
xh.open("POST", uploadhandlerpath + "?name=" + encodeURIComponent(file.name || "edited-image.png") + "&type=" + encodeURIComponent(file.type || "image/png") + "&size=" + file.size, true);
xh.onload = xh.onabort = xh.onerror = xh.ontimeout = function (pe) {
if (finished) return;
var restoreFocus = dialoginner.contains(document.activeElement);
dialogouter.remove();
try { if (restoreFocus && previousFocus && previousFocus.isConnected && typeof previousFocus.focus === "function") previousFocus.focus(); } catch (error) { /* Never prevent upload settlement. */ }
if (pe.type == "load") {
if (xh.status != 200) {
if (xh.responseText.startsWith("ERROR:")) {
callback(null, "http-error-" + xh.responseText.substring(6));
}
else {
callback(null, "http-error-" + xh.status);
}
}
else if (xh.responseText.startsWith("READY:")) {
var resolved;
try {
var value = xh.responseText.substring(6).trim();
if (!value) throw new Error('Missing URL');
resolved = new URL(value, new URL(uploadhandlerpath, document.baseURI));
if (resolved.protocol !== 'http:' && resolved.protocol !== 'https:') throw new Error('Invalid URL');
} catch (error) { callback(null, 'Server returned an invalid image URL'); return; }
callback(resolved.href);
}
else {
callback(null, "http-error-" + xh.responseText);
}
}
else if (uploadcancelled) {
callback(null, "cancelled");
}
else {
callback(null, pe.type);
}
}
xh.upload.onprogress = function (pe) {
if (finished) return;
//pe.total
var percent = totalsize > 0 ? Math.max(0, Math.min(100, Math.floor(100 * (sentsize + pe.loaded) / totalsize))) : 0;
line2.innerText = percent + "%";
progressbar.setAttribute("aria-valuenow", String(percent));
progressbg.style.width = percent + "%";
}
xh.send(file);
return function () { if (!finished) { uploadcancelled = true; xh.abort(); } };
} catch (error) {
if (finished) throw error;
var restoreFailedFocus = dialoginner && dialoginner.contains(document.activeElement);
if (dialogouter && dialogouter.parentNode) dialogouter.parentNode.removeChild(dialogouter);
try { if (restoreFailedFocus && previousFocus && previousFocus.isConnected && typeof previousFocus.focus === "function") previousFocus.focus(); } catch (focusError) { /* Preserve upload error. */ }
callback(null, 'Could not start upload');
}
}
// Single-page demo state, not an editor API.
var rte_upload_state = { pending: 0, error: '' };
var config = {
file_upload_handler: rte_file_upload_handler,
maxUploadFileSize: 4000000, // Match your server byte limit.
allowBase64Images: false
};
var editor = new RichTextEditor("#div_editor", config);上传的图片如何对齐
编辑器本身不应用任何对齐方式。插入的图片是一个没有 align 属性、也没有样式的内联 <img>,因此它位于文本之中,遵循所在段落的对齐方式——默认左对齐,除非该段落居中或右对齐:
<!-- what the editor inserts: no align attribute, no style -->
<p>Some text <img src="/imageuploads/20260918-a1b2c3.png" loading="lazy" decoding="async"></p>
<!-- the same image, centred only because its paragraph is centred -->
<p style="text-align:center"><img src="/imageuploads/20260918-a1b2c3.png" loading="lazy" decoding="async"></p>因此,并不存在可以修改的“默认图片对齐”设置。如果图片在所有地方都显示为居中,原因是您自己样式表中的某条规则,或者段落本身是居中的。请检查您存储的 HTML:如果 <img> 没有任何对齐设置,那就是您的 CSS 造成的。
用户可以选中图片,再使用图片工具栏上的对齐菜单,为每张图片单独设置对齐。要改变所有图片的行为,请在您自己的 CSS 中编写规则,并把同样的规则提供给编辑器,使编辑视图与发布后的页面一致:
var config = {};
// Applies inside the editing area. Use the same rule on the pages that display
// the saved content, or the two will not look alike.
config.contentCssText = "p { text-align: left; } p img { float: none; }";
// Or load one stylesheet in both places:
config.contentCssUrl = "/css/editor-content.css";延伸阅读
- 正确地实现图片和文件上传——处理程序必须覆盖的失败情况:中途失败的上传、粘贴的图片、孤立的文件。
- ASP.NET Web Forms 和 MVC 中的图片上传与图片库以及 ASP.NET Core 中的图片上传与图片库——处理程序代码、图片的存储位置,以及可浏览文件夹的图片库端点。
- PHP 中的图片上传与图片库——PHP 的同等内容,包括上面提到的上传限制。