

editor.md 是一款支持markdown的WEB编辑器,用户可以在自己的网站上使用其进行内容编辑,这在之前的博客里有详细的使用介绍。
本文主要介绍如何自定义一个支持七牛上传的editor.md插件。
editor.md 自带图片上传组件
首先介绍一下使用editor.md的自带图片上传组件的配置,这对下面开发七牛上传插件有很大的帮助。
初始化 editor.md 时使用以下配置即可打开图片上传功能。
<div class="editormd" id="id_editormd">
<textarea class="editormd-markdown-textarea" name="markdown"></textarea>
<textarea class="editormd-html-textarea" name="html"></textarea>
</div>
<script>
editormd("id_editormd", {
width : "100%",
height : 540,
syncScrolling : "single",
path : "${rc.contextPath}/resources/editormd/lib/",
imageUpload : true,
imageFormats : [ "jpg", "jpeg", "gif", "png", "bmp", "webp" ],
imageUploadURL : "/uploadfile",
saveHTMLToTextarea : true
}
</script>
其中的/uploadfile
指向了图片上传的后台URL地址,在editor.md进行图片上传时,将构造一个 enctype="multipart/form-data"
的form表单,然后使用iframe的方式向该URL异步POST上传,上传后后台返回一个固定格式json,包含了上传成功与否,以及上传成功后的图片URL信息,json格式如下:{"success":0|1,"message":"xxx","fileName":"imageURL"}

以JAVA为例,编写后台上传URL服务器端代码(SpringMVC):
@RequestMapping(value="/uploadfile",method=RequestMethod.POST)
public ModelAndView upload(HttpServletRequest request, HttpServletResponse response, @RequestParam(value = "editormd-image-file", required = false) MultipartFile attach){
String rootPath = request.getSession().getServletContext().getRealPath("/resources/upload/");
response.setHeader( "Content-Type" , "text/html" );
ModelAndView mv = new ModelAndView("upload_result");
try {
File filePath=new File(rootPath);
/**
* 文件路径不存在则需要创建文件路径
*/
if(!filePath.exists()){
filePath.mkdirs();
}
//最终文件名
File realFile=new File(rootPath + File.separator + UUID.randomUUID().toString() + ".jpg");
FileUtils.copyInputStreamToFile(attach.getInputStream(), realFile);
mv.addObject("success", 1);
mv.addObject("message", "上传成功");
mv.addObject("fileName", realFile.getName());
} catch (Exception e) {
mv.addObject("success", 0);
mv.addObject("message", "上传失败,异常信息:" + e.getMessage());
}
return mv;
}
//upload_result.json:
//{"success": ${success}, "message":"${message}"<#if fileName ??>,"url":"/resources/upload/${fileName}"</#if>}
以上就是使用editor.md图片上传的整个流程了。
七牛云存储
七牛云存储 是国内比较著名的云存储服务,为免费用户提供高达10G的存储空间,API齐全,文档够详细,上传下载速度快,丰富的水印、缩放、防盗链等功能。
使用云存储对于博客类网站是非常不错的主意,节省了大量的带宽和流量,毕竟我们购买的VPS,包括阿里云、腾讯云等产品,都是流量计费比包年付费划算的,如果采用流量计费的方式,图片流量绝对会比HTML文本大得多,而如果采用包年包月计费,这时候带宽是一定的,加载图片会占用大量带宽,从而影响页面加载速度。此外在进行博客搬家时,我们无需面对高达几百M甚至上G的上传文件夹,仅需将WEB代码和mySQL数据库拷贝一份出来即可。综上所述,为了节省带宽和流量资源,提高网页加载速度,并且简化搬家过程,使用云服务器进行图片资源的第三方存储,是绝对有好处的。
为 editor.md 设计七牛云存储插件
我百度了一下 editor.md 的云存储插件,并没有发现任何可以拿来直接用的插件,于是萌生了自己开发editor.md插件的想法。
上传流程
七牛的上传流程是这样的:
第一步:浏览器客户端向本地URL发送GET请求,请求一串上传令牌token,服务器端通过七牛的公钥、密钥和空间名称,以及自定义的上传策略,构造一串token给客户端,这个token是一组加密过的散列码,长度不固定。这个过程推荐使用ajax请求和json传输。
第二步:浏览器将拿到的令牌token和要上传的图片一起通过multipart加密form传输给七牛服务器,七牛服务器对token使用密钥验证和解析,得到是谁上传的,上传到哪个资源空间等信息,然后返回一个处理结果给浏览器。这其中包含上传成功与否,图片上传后的URL后缀地址,错误信息等。
以上两步即完成了七牛云服务器上传的全部,下面着手来开发:
插件入口
首先editor.md提供了自定义插件接口,这对我们编写插件带来了可行性性,官方的自定义插件文档在这里:https://pandao.github.io/editor.md/examples/define-plugin.html
依照这个示例程序,改造editor.md的初始化配置函数:
editormd("domId", {
width : "100%",
height : 540,
syncScrolling : "single",
path : "${rc.contextPath}/resources/editormd/lib/",
imageUpload : true,
imageFormats : [ "jpg", "jpeg", "gif", "png", "bmp", "webp" ],
imageUploadURL : "/uploadfile",
saveHTMLToTextarea : true,
previewTheme : "dark",
toolbarIcons : function() {
return ["bold", "del", "italic", "hr", "image", "qiniu", "table", "datetime", "|", "preview", "watch", "|", "fullscreen"];
},
//配置七牛上传插件
toolbarIconsClass : {
qiniu : "fa-cloud-upload"
},
toolbarHandlers : {
qiniu : function(cm, icon, cursor, selection) {
this.imageDialogQiniu();
}
},
qiniuTokenUrl : "/getQiniuToken", //本地服务器获取七牛token的url
qiniuPublishUrl : "https://files.hexcode.cn/" //远程七牛服务器个人发布地址
});
可以看到上面我们配置了一个一个名为qiniu
的插件,并且定义了它的入口函数:imageDialogQiniu();
,并添加了两个配置字符串qiniuTokenUrl
和qiniuPublishUrl
,这两个字符串第一个是本地服务器URL,用来从本地获取上传令牌,这个后面会讲;第二个URL是在你注册七牛帐户后七牛分配给你的默认空间URL前缀,你可以付费开通正式帐号来自定义这个前缀,我暂时没有特别的需求,先使用的七牛免费功能,等后续有更多需求的时候再考虑是否成为七牛付费会员。
七牛空间前缀及上传令牌

注册成为七牛会员后,进入七牛控制台,添加一个「对象存储」资源,如图所示,右边的「我的资源」中会出现这个资源空间,点击这个资源,即可看到自己的上传空间的URL前缀了,上面讲过,免费用户的空间前缀是随机的HASH码,付费以后可以自定义这个前缀,界面如下:

下面我们来查看七牛分配给我们构造上传令牌时所需的公钥和密钥:

右上方「个人面板」->「密钥管理」即可查看,其中AK是公钥,SK是密钥,虽说现在的RSA加密机制中公钥是可以传播的,密钥是不可以传播的,但我还是建议这两个密钥都不要随意公布,所以这里我隐藏了。
好了,URL前缀,公钥,密钥都有了,七牛给我们的这三个看上去像乱码,却蕴含特殊意义的字符串都拿到手了,继续我们的开发吧。
编写插件JS

在editor.md的文档结构中,plugins文件夹是所有的自带的插件,我们添加七牛插件也是如此,添加一个文件夹,名为image-dialog-qiniu
,并创建image-dialog-qiniu.js
。
其中的代码我大部分模仿了原有的image-dialog
插件,但是改造了其有点不知所谓的iframe上传方法,使用我偏爱的jQuery ajax post异步上传,掌握这些知识点的朋友应该能够看明白,代码如下:
/*!
* Image (upload) dialog By Qiniu plugin for Editor.md
*
* @file image-dialog-qiniu.js
* @author newflydd@189.cn
* @version 1.3.4
* @updateTime 2016-09-02
* {@link https://www.hexcode.cn}
* @license MIT
*/
(function() {
var factory = function (exports) {
var pluginName = "image-dialog-qiniu";
exports.fn.imageDialogQiniu = function() {
var _this = this;
var cm = this.cm;
var lang = this.lang;
var editor = this.editor;
var settings = this.settings;
var cursor = cm.getCursor();
var selection = cm.getSelection();
var imageLang = lang.dialog.image;
var classPrefix = this.classPrefix;
var iframeName = classPrefix + "image-iframe";
var dialogName = classPrefix + pluginName, dialog;
var ajaxToken = ""; //向本地服务器请求七牛的上传token
cm.focus();
var loading = function(show) {
var _loading = dialog.find("." + classPrefix + "dialog-mask");
_loading[(show) ? "show" : "hide"]();
};
if (editor.find("." + dialogName).length < 1) {
var guid = (new Date).getTime();
var action = settings.imageUploadURL + (settings.imageUploadURL.indexOf("?") >= 0 ? "&" : "?") + "guid=" + guid;
if (settings.crossDomainUpload)
{
action += "&callback=" + settings.uploadCallbackURL + "&dialog_id=editormd-image-dialog-" + guid;
}
var dialogContent = ( (settings.imageUpload) ? "<form id=\"qiniuUploadForm\" method=\"post\" enctype=\"multipart/form-data\" class=\"" + classPrefix + "form\" onsubmit=\"return false;\">" : "<div class=\"" + classPrefix + "form\">" ) +
"<label>" + imageLang.url + "</label>" +
"<input type=\"text\" data-url />" + (function(){
return (settings.imageUpload) ? "<div class=\"" + classPrefix + "file-input\">" +
"<input type=\"file\" name=\"file\" accept=\"image/*\" />" +
"<input type=\"submit\" value=\"七牛上传\" click=\"alert('dd')\" />" +
"</div>" : "";
})() +
"<br/>" +
"<input name=\"token\" type=\"hidden\" value=\"" + ajaxToken + "\">" + //七牛的上传token
"<label>" + imageLang.alt + "</label>" +
"<input type=\"text\" value=\"" + selection + "\" data-alt />" +
"<br/>" +
"<label>" + imageLang.link + "</label>" +
"<input type=\"text\" value=\"https://\" data-link />" +
"<br/>" +
( (settings.imageUpload) ? "</form>" : "</div>");
dialog = this.createDialog({
title : imageLang.title,
width : (settings.imageUpload) ? 465 : 380,
height : 254,
name : dialogName,
content : dialogContent,
mask : settings.dialogShowMask,
drag : settings.dialogDraggable,
lockScreen : settings.dialogLockScreen,
maskStyle : {
opacity : settings.dialogMaskOpacity,
backgroundColor : settings.dialogMaskBgColor
},
buttons : {
enter : [lang.buttons.enter, function() {
var url = this.find("[data-url]").val();
var alt = this.find("[data-alt]").val();
var link = this.find("[data-link]").val();
if (url === "") {
alert(imageLang.imageURLEmpty);
return false;
}
var altAttr = (alt !== "") ? " \"" + alt + "\"" : "";
if (link === "" || link === "https://")
{
cm.replaceSelection("");
}
else
{
cm.replaceSelection("[](" + link + altAttr + ")");
}
if (alt === "") {
cm.setCursor(cursor.line, cursor.ch + 2);
}
this.hide().lockScreen(false).hideMask();
return false;
}],
cancel : [lang.buttons.cancel, function() {
this.hide().lockScreen(false).hideMask();
return false;
}]
}
});
dialog.attr("id", classPrefix + "image-dialog-" + guid);
if (!settings.imageUpload) {
return ;
}
var fileInput = dialog.find("[name=\"file\"]");
var submitHandler = function() {
$.ajax({
url : settings.qiniuTokenUrl,
type : "post",
dataType : "json",
timeout : 2000,
beforeSend : function(){loading(true);},
success : function(result){
if(result.resultCode == 0){
ajaxToken = result.data;
if(ajaxToken === ""){
loading(false);
alert("没有获取到有效的上传令牌,无法上传!");
return;
}
dialog.find("[name=\"token\"]").val(ajaxToken);
var formData = new FormData( $("#qiniuUploadForm")[0] );
dialog.find("[name=\"token\"]").val(); //隐藏令牌
$.ajax({
url: 'https://upload.qiniu.com/' ,
type: 'POST',
data: formData,
dataType: "json",
beforeSend:function(){loading(true);},
cache: false,
contentType: false,
processData: false,
timeout : 30000,
success: function (result) {
dialog.find("[data-url]").val(settings.qiniuPublishUrl + result.key);
},
error : function(){alert("上传超时");},
complete:function(){loading(false);}
});
}
else
alert(result.message);
}
});
};
dialog.find("[type=\"submit\"]").bind("click", submitHandler);
fileInput.bind("change", function() {
var fileName = fileInput.val();
var isImage = new RegExp("(\\.(" + settings.imageFormats.join("|") + "))$"); // /(\.(webp|jpg|jpeg|gif|bmp|png))$/
if (fileName === "")
{
alert(imageLang.uploadFileEmpty);
return false;
}
if (!isImage.test(fileName)){
alert(imageLang.formatNotAllowed + settings.imageFormats.join(", "));
return false;
}
dialog.find("[type=\"submit\"]").trigger("click");
});
}
dialog = editor.find("." + dialogName);
dialog.find("[type=\"text\"]").val("");
dialog.find("[type=\"file\"]").val("");
dialog.find("[data-link]").val("https://");
this.dialogShowMask(dialog);
this.dialogLockScreen();
dialog.show();
};
};
// CommonJS/Node.js
if (typeof require === "function" && typeof exports === "object" && typeof module === "object")
{
module.exports = factory;
}
else if (typeof define === "function") // AMD/CMD/Sea.js
{
if (define.amd) { // for Require.js
define(["editormd"], function(editormd) {
factory(editormd);
});
} else { // for Sea.js
define(function(require) {
var editormd = require("./../../editormd");
factory(editormd);
});
}
}
else
{
factory(window.editormd);
}
})();
后面一段的// CommonJS/Node.js
是原模板自带的,可能是为Node.js编写的WEB服务器提供功能,我们用不到,这里就不深究了。
本地服务器令牌生成
在刚刚那个插件JS中,我们首先向本地服务器索取上传令牌,这个令牌是一串hash字符串,内容包括了从申请令牌到上传图片成功或者失败之间的上传时限,上传的空间,以及更多的上传策略。构造这一串令牌的代码在本地WEB服务器中实现,以JAVA为例:
/**
* 七牛上传令牌获取
* @return
* @throws JblogException
*/
@RequestMapping(value="/getQiniuToken",method=RequestMethod.POST)
public ModelAndView qiniu() throws JblogException{
Result result = new Result();
if(StringUtils.isEmpty(BlogConfig.QN_ACCESSKEY) || StringUtils.isEmpty(BlogConfig.QN_SECRETKEY)){
result.setCode(Result.FAIL);
result.setMessage("数据库中没有正确的七牛服务器密钥,无法生成令牌");
return new ModelAndView("ajaxResult", "result", result);
}
Auth auth = Auth.create(BlogConfig.QN_ACCESSKEY, BlogConfig.QN_SECRETKEY);
String token = auth.uploadToken(
"blog-images", //空间名称
null, //key,最终资源名称,一般为空,让服务器自己生成
3600, //3600秒,上传时限
new StringMap() //其他上传策略
.put("saveKey", UUID.randomUUID().toString() + "$(ext)")
);
result.setData("\"" + token + "\"");
return new ModelAndView("ajaxResult", "result", result);
}
其中的Auth是七牛官网提供的jar包中的,不需要直接下载,可以使用maven或者gradle获取,七牛已经将这个jar提交到了maven国际公共库了,介绍文档在这里:https://developer.qiniu.com/code/v7/sdk/java.html
我用的gradle,直接添加:
compile 'com.qiniu:qiniu-java-sdk:7.x.+'
到此,我们整个的editor.md七牛插件就编写好了。如下方所示:

回顾一下开发步骤
- 在editor.md的初始化函数中添加插件的描述。
- 在editor.md的plugins文件中添加相关的插件文件夹和插件JS,js代码可以根据插件模板书写。
- 到七牛官网注册,并在个人中心添加资源空间,收集三个字符串数据:资源空间的url前缀,AK公钥,SK私钥。
- 编写本地服务器端代码,使用AK和SK构造token上传令牌,构造时使用的类来自七牛JAVA文档。
其中最复杂的是第二步,我所公布的插件JS篇幅比较长,需要耐心观察。其中的知识点包括jQuery;动态生成form;ajax提交等,这些都是互联网应用开发的基础知识点。