Upload files

You write the upload handler. It receives the file the user inserted, pasted or dropped, sends it to your server, and hands the editor back the URL to put in the document. The samples in the download include a working handler for ASP.NET Web Forms, MVC, ASP.NET Core and PHP, plus the client script below.

Configure a handler, or files are embedded in the content

With no handler configured, nothing is uploaded and the file still has to go somewhere, so it goes into the document:

  • An image is embedded as a base64 data:URL. The picture becomes part of the HTML you save, and base64 adds about a third: a 3 MB photo turns into roughly 4 MB of text in one field. This is the usual cause of a save that fails, truncates, or times out when writing to the database.
  • A document is inserted as a link carrying the file name, but without a working address: the editor points it at a temporary blob: URL, and the content sanitizer removes that, because blob: is not one of the schemes it allows. The reader gets a link that cannot be opened. Set fileUploadLocalObjectUrl: false to refuse the insert and tell the user instead.

With a handler, the content holds a short URL such as /imageuploads/20260918-a1b2c3.png. To check an existing document, look at the HTML you stored: src="data:image/... means the handler is not being used.

The Insert Gallery dialog’s Upload button looks only for the window.rte_file_upload_handler global. If you set only the per-editor file_upload_handler option, the gallery shows no Upload button.

The short way: give the editor a URL

If your server already accepts a file and answers with its URL, you do not need to write any JavaScript at all:

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);

The editor POSTs the file as multipart/form-dataand reads the URL out of the response. It understands the shapes the other editors’ endpoints already answer with, so a server written for TinyMCE or CKEditor works here unchanged:

Response bodyWritten for
{"location": "/img/x.png"}TinyMCE
{"url": "/img/x.png"}CKEditor simple upload adapter
{"urls": {"default": "..."}}CKEditor, multiple sizes
READY:/img/x.pngthe sample handlers below
{"error": {"message": "..."}} or ERROR:messageeither, reported as a failure

A response in none of those shapes is treated as a failed upload rather than inserted, so an endpoint that answers OK cannot become an image source.

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 };

Each uploaded file is given a unique name by default, because two people uploading image.pngto the same endpoint must not overwrite each other. Retries, cancellation and the progress event all work the same as with your own handler. Write a handler instead when you need to resize before sending, upload to S3 directly, or show your own dialog — it takes precedence whenever both are set.

Syntax

// 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 — the browser’s File object, with name, size and type. Send it with XMLHttpRequest or fetch.

callback

Type: Function callback(url, errorMessage)— call it exactly once. On success pass the stored URL; on failure pass null and a message, which the editor shows to the user. An upload the user cancels is a failure too.

optionalIndex, optionalFiles

When several files are inserted at once, the handler is called for each one: optionalFiles is the whole set and optionalIndexis the position of this file, so progress can be reported as “2 of 5”.

The sample handlers answer in plain text: READY:<url> with status 200, or ERROR:<message> with status 500. The client script below turns those into the two callback forms.

Cancelling an upload

Return an object with an abort() method (or a bare abort function) from the handler, and the editor can stop a request that is still running:

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

The editor cancels on its own when the image the upload belongs to is no longer in the document, and when destroy()runs. A cancelled upload’s callback is ignored even if the request finishes anyway, so a slow response cannot put a deleted image back. An uploadcancel event fires with the file and the reason. Returning nothing still works: the upload simply cannot be aborted.

Images already embedded in the content

The handler is called for files the user inserts, pastes or drops. Content that arrives another way — set with setHTMLCode, pasted from a web page or another editor, or restored from a draft — keeps its base64 data: URL, so those images are saved inside the HTML and your handler never sees them. Call uploadEmbeddedImages before saving:

// Before saving, upload anything still embedded as base64.
editor.uploadEmbeddedImages(function (summary) {
    // summary: { uploaded: 2, failed: 0, skipped: 0, errors: [] }
    if (summary.failed) return;
    document.getElementById("content").value = editor.getHTMLCode();
    form.submit();
});

It walks the current document, converts every data:image/ source into a File, sends them through the handler one at a time, and replaces each source with the URL you return. Images already on http(s) are left alone. With no handler configured nothing is changed and every image is counted as skipped.

Uploading automatically, and retrying a failure

automaticUploads runs the scan above for you, so an image never reaches your database as base64 because someone forgot to call it:

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

It runs after setHTMLCode and after inserted or pasted HTML, debounced, and does nothing when no handler is configured. uploadRetryCount covers the other common failure: one dropped connection used to leave the image embedded for good. The retry is silent when it succeeds, each attempt raises an uploadretryevent, and once the attempts run out your callback’s error is reported as before.

Limiting the size of uploads and of the content

Set maxUploadFileSize and the editor refuses a larger file itself, before reading it. Nothing is embedded and nothing is sent, and the user is told which size was rejected and what the limit is:

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;
};

It covers every route a file arrives by: the image dialog, the document dialog, paste, and drag-and-drop. Images already embedded inside pasted HTML (from Word, for example) are not covered, because refusing one mid-paste would leave the document half converted; maxHTMLLength below is the backstop for that.

Keep the check in your handler as well, since a browser is not a trustworthy place to enforce a limit. The sample handler rejects anything over 4,000,000 bytes and answers ERROR:file too big.

Two server limits must also be above your largest file:

  • ASP.NET: maxRequestLength in <httpRuntime> (kilobytes, default 4096) and maxAllowedContentLength in <requestLimits>(bytes, default about 30 MB).
  • PHP: upload_max_filesize(default 2 MB) and post_max_size. Below these, PHP discards the file before your handler runs and the editor reports that no file was received.

To cap the document itself, set a character limit. Both options are off by default (0), and when an edit crosses the limit the editor reverts to the last good state and shows a message rather than saving something your database will reject:

var config = {};
config.maxHTMLLength = 60000;  // characters INCLUDING the HTML tags; 0 = no limit
config.maxTextLength = 0;      // characters EXCLUDING the tags; 0 = no limit

Set maxHTMLLength a little below the size of the column you store the HTML in. Handle the reachmaxlength hook to replace the default alert with your own dialog.

Shrinking photographs before they are uploaded

A phone camera produces files far larger than any web page needs, and refusing them with maxUploadFileSize only tells the writer to go and find a different picture. The editor has no resizing setting of its own; the handler is where it belongs, because only your application knows how large the stored copy should be. Scale the image on a canvas and upload the result instead of the original:

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;
};

Measured on a 2400 × 1600 photograph: the 3.9 MB original is uploaded as a 1600 × 1067 JPEG of 94 KB, and the document holds the URL your server returned.

Two things to keep in mind. The conversion to JPEG discards transparency, so branch on file.typeif your users paste logos as well as photographs. And re-encoding strips the file's metadata — usually welcome, since that is where a photograph's GPS coordinates live, but not if you rely on it.

When an upload fails

Call the callback with null and a message: callback(null, "http-error-413"). The editor shows The file could not be uploaded. followed by your message in brackets, and the uploadfailedhook fires first, so you can show your own dialog instead. That hook receives your message exactly as you passed it — the raw code, not the sentence around it — so you can branch on it.

A failed upload does not leave the image out: the editor falls back to embedding it in the content as a base64 data: URL, the same as if no handler were configured. An upload endpoint that quietly rejects large files therefore produces exactly the oversized HTML you were trying to avoid. Set maxUploadFileSize to the limit your endpoint enforces, so the file is refused before it is read.

Template JavaScript code

This is the client script from the samples: it shows a progress dialog with a Cancel button, posts the file, and calls back with the URL or the error.

var uploadhandlerpath = "/imageupload.ashx";


function rte_file_upload_handler(file, callback, optionalIndex, optionalFiles) {

    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;background-color:rgba(15,23,42,0.28)");
    dialogouter.setAttribute("role", "dialog");
    dialogouter.setAttribute("aria-modal", "true");
    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;");
    line1.id = "rte-upload-progress-title-" + Date.now();
    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;
        }
        console.log(totalsize, optionalIndex, optionalFiles)
        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;");
    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.innerText = "Cancel";
    btn.onclick = function () {
        uploadcancelled = true;
        xh.abort();
    }

    var xh = new XMLHttpRequest();
    xh.open("POST", uploadhandlerpath + "?name=" + encodeURIComponent(file.name) + "&type=" + encodeURIComponent(file.type) + "&size=" + file.size, true);
    xh.onload = xh.onabort = xh.onerror = function (pe) {
        console.log(pe);
        console.log(xh);
        dialogouter.remove();
        if (pe.type == "load") {
            if (xh.status != 200) {
                console.log("uploaderror", pe);
                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:")) {
                console.log("File uploaded to " + xh.responseText.substring(6));
                callback(xh.responseText.substring(6));
            }
            else {
                callback(null, "http-error-" + xh.responseText);
            }
        }
        else if (uploadcancelled) {
            console.log("uploadcancelled", pe);
            callback(null, "cancelled");
        }
        else {
            console.log("uploaderror", pe);
            callback(null, pe.type);
        }
    }
    xh.upload.onprogress = function (pe) {
        console.log(pe);
        //pe.total
        var percent = totalsize ? Math.floor(100 * (sentsize + pe.loaded) / totalsize) : 0;
        line2.innerText = percent + "%";

        progressbg.style.cssText = "width:" + percent + "%";
    }
    xh.send(file);

    // Let the editor abort this request too: it does so when the image is deleted
    // while the upload runs, when destroy() is called, and on cancelUploads().
    return {
        abort: function () {
            uploadcancelled = true;
            xh.abort();
        }
    };
}

How an uploaded image is aligned

The editor applies no alignment of its own. An inserted image is an inline <img> with no alignattribute and no style, so it sits in the text and follows the alignment of the paragraph that contains it — left, unless that paragraph is centred or right-aligned:

<!-- 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>

There is therefore no “default image alignment” setting to change. If images look centred everywhere, the cause is a rule in your own stylesheet, or paragraphs that are centred. Check the HTML you stored: if the <img> carries no alignment, it is your CSS.

Users set alignment per image by selecting it and using the alignment menu on the image toolbar. To change what every image does, write the rule in your own CSS and give the editor the same rule, so the editing view matches the published page:

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";

Further reading