← 全部文档

上传文件

遇到问题?请按照图片上传故障排查指南排查请求缺失、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";

延伸阅读