Editor.md配合UFile图片上传、回显
Editor.md是一个开源的Markdown在线编辑器,可作为富文本编辑器使用,UFile是Ucloud对象云存储的服务。
1. Editor.md
官网:

引入
下载在Github的源码,然后在Html中引入;
<link rel="stylesheet" href="/editor.md/css/editormd.min.css" />
<script src="/editor.md/editormd.js"></script>
Markdown编辑器
初始化编辑器,可输入内容
<div id="editor" class="sh_MdEditor">
<textarea style="display:none;" name="content" id="content" placeholder="在此输入文章内容...">
</textarea>
</div>
<!--初始化编辑器-->
<script type="text/javascript">
$(function () {
var editor = editormd('editor', {
width: '100%',
height: '100%',
// markdown: "xxxx", // dynamic set Markdown text
path: '/editormd/lib/', // Autoload modules mode, codemirror, marked... dependents libs path
delay: 0,
codeFold: true,
htmlDecode: true,
emoji: true,
//图片上传
imageUpload: true,
imageFormats: ['jpg', 'jpeg', 'gif', 'png', 'bmp', 'webp'],
imageUploadURL: '/imageUpload', // Upload url
crossDomainUpload: false, // Enable/disable Cross-domain upload
uploadCallbackURL: '',
placeholder: '在此输入文章内容,使用markdown语法...',
description: 'Markdown 文本编辑',
lang: {
// Language data, you can custom your language.
description: 'Markdown编辑器<br/>Markdown editor.',
},
});
});
</script>
Markdown解析
对Markdown内容进行解析,显示成html,注意,需要额外引入:
<script src="/editormd/lib/marked.min.js"></script>
<script src="/editormd/lib/prettify.min.js"></script>
<!--md解析器 -->
<div id="md_viewer" class="sh_MdViewer">
<textarea style="display:none;" th:text="${invitationDetail.content}"></textarea>
</div>
<script>
$(function () {
//md解析器
var md_viewer = editormd.markdownToHTML('md_viewer', {
// markdown : "[TOC]\n### Hello world!\n## Heading 2", // Also, you can dynamic set Markdown text
htmlDecode: true, // Enable / disable HTML tag encode.
// htmlDecode : "style,script,iframe", // Note: If enabled, you should filter some dangerous HTML tags for website security.
});
});
</script>
图片上传前端
配置
图片上传需要在初始化md编辑器的js里设置:
//图片上传
imageUpload: true,//启用图片上传
imageFormats: ["jpg", "jpeg", "gif", "png", "bmp", "webp"],//文件格式限制
imageUploadURL: "/imageUpload", // 上传地址
crossDomainUpload: false, // 是否启用跨域上传
uploadCallbackURL: "", //上传完成后的回调地址
表单
编辑器上传图片,使用的是<ifram>里的form表单,如图:

只是普通的上传文件,然后对文件进行格式限制,传递文件的参数名为:editormd-file-input

Json Data
编辑器需要服务器返回Json数据,以此获得上传结果、图片回显地址。
{
"success": 1, // 0 表示上传失败,1 表示上传成功
"message": "上传成功或上传失败及错误信息等。",
"url": "回显需要的图片地址" // 上传成功时才返回
}
图片上传Controller
通过前端传递过来的参数、需要的返回值,便可以写出一个临时的Controller:
package cn.shirtiny.community.SHcommunity.Controller;
import ...
@Controller
public class ImageController {
//暂无service
//imageService
//md编辑器的图片上传表单的name参数值(放在.properties文件中,Md_Editor_imageFile_name=editormd-image-file)
@Value("${Md_Editor_imageFile_name}")
private String Md_Editor_imageFile_name;
//md图片上传以及回显
@RequestMapping(value = "/imageUpload")
@ResponseBody
public Md_ImageUpResultDTO uploadImage(HttpServletRequest request){
//转换request
MultipartHttpServletRequest multipartRequest= (MultipartHttpServletRequest) request;
String downloadUrl="";
//需要md图片表单提交的文件name
MultipartFile file = multipartRequest.getFile(Md_Editor_imageFile_name);
//调用上传服务
//downloadUrl = imageService.upload();
return new Md_ImageUpResultDTO(1,"上传成功!",downloadUrl);
}
}
其中,Md_ImageUpResultDTO是服务器上传完成后,返回信息的封装
package cn.shirtiny.community.SHcommunity.DTO;
import lombok.Data;
@Data
public class Md_ImageUpResultDTO {
//表示是否上传成功
int success;
//提示
String message;
//图片地址
String url;
public Md_ImageUpResultDTO(int success, String message, String url) {
this.success = success;
this.message = message;
this.url = url;
}
}
2. UFile
使用UFile作为存储上传文件的云空间,因为有20G免费空间。
UFile SDK
Github地址,这里用java版的
Maven引入
<dependency>
<groupId>cn.ucloud.ufile</groupId>
<artifactId>ufile-client-java</artifactId>
<version>2.2.1</version>
</dependency>
配置信息
公钥、密钥在令牌管理里生成

为了方便修改,将这些固定信息放在xx.properties文件里
#md编辑器的图片上传表单的name参数值,由插件表单决定的固定值
Md_Editor_imageFile_name=editormd-image-file
#是否允许图片上传服务修改文件的名字
ImageUploadService_isAllownRename=true
#ucloud对象存储 java JDK https://github.com/ucloud/ufile-sdk-java
#ucloud对象存储,令牌SHtoken,https://console.ucloud.cn/ufile/token
ucloud_uFile_SHtoken_PublicKey=123456
ucloud_uFile_SHtoken_PrivateKey=123456
#命名空间的名字
ucloud_uFile_bucket_name=shirtinycn
#命名空间bucket所在的地区编码,地区编码列表 https://docs.ucloud.cn/api/summary/regionlist.html
ucloud_uFile_bucket_region=cn-gd
#域名后缀ufileos.com
ucloud_uFile_bucket_proxySuffix=ufileos.com
#临时下载地址的过期时间,315360000 --> 10 * 365 * 24 * 60 * 60s = 10年
ucloud_uFile_downloadURL_expiresDuration=315360000
文件上传Service
- 接口:
package cn.shirtiny.community.SHcommunity.Service;
import java.io.InputStream;
public interface ImageService {
//图片文件上传
String upload(InputStream inputStream, String mimeType, boolean allownRename, String clientFileName);
//生成随机文件名
String createRandomName(String clientFileName);
}
- 实现类:
授权以及配置
// 对象相关API的授权器
ObjectAuthorization OBJECT_AUTHORIZER = new UfileObjectLocalAuthorization(myPublicKey, myPrivateKey);
// 对象操作需要ObjectConfig来配置您的地区和域名后缀
ObjectConfig config = new ObjectConfig(region, proxySuffix);
执行上传
PutObjectResultBean response;
{
try {
response = UfileClient.object(OBJECT_AUTHORIZER, config)
//可以使用文件的方式,此上传方法有很多同类型的方法
.putObject(inputStream, mimeType)
.nameAs(serverFileName)
//我命名空间的名字
.toBucket(bucketName)
/**
* 是否上传校验MD5, Default = true
*/
// .withVerifyMd5(false)
/**
* 指定progress callback的间隔, Default = 每秒回调
*/
// .withProgressConfig(ProgressConfig.callbackWithPercent(10))
/**
* 配置进度监听
*/
.setOnProgressListener(new OnProgressListener() {
@Override
public void onProgress(long bytesWritten, long contentLength) {
//已上传/总长度
System.out.println(bytesWritten + "/" + contentLength + "进度:" + (bytesWritten * 100) / contentLength + "%");
}
}).execute();
响应以及回显文件地址
//上传完成后,查看response,然后获得刚刚上传图片的临时地址
//上传成功RetCode是0,错误时的response:{"ResponseCode":400,"RetCode":-30010,"ErrMsg":"bucket not exist","X-SessionId":"0e9df91b-5d69-4e9b-bfeb-5d9b8c182869"}
if (response.getRetCode() == 0) {
//获取刚刚上传的文件地址,设置过期时间
downloadUrl = UfileClient.object(OBJECT_AUTHORIZER, config)
.getDownloadUrlFromPrivateBucket(serverFileName, bucketName, expiresDuration)
.createUrl();
return downloadUrl;
//出错时,throw new Md_ImageUploadFailedException(e.getMessage());
全局异常处理
package cn.shirtiny.community.SHcommunity.Advice;
import ...
@ControllerAdvice//结合@ExceptionHandler用于全局异常的处理
public class myControllerAdvice {
@ExceptionHandler(Md_ImageUploadFailedException.class)
@ResponseBody
public Md_ImageUpResultDTO uploadFileErr(Throwable e){
System.out.println("文件上传失败");
//返回图片上传的失败结果,以及错误信息
return new Md_ImageUpResultDTO(0,e.getMessage(),null);
}
}
Md_ImageUploadFailedException是自定义的异常。
3. 后端完整代码
Controller
package cn.shirtiny.community.SHcommunity.Controller;
import ...
@Controller
public class ImageController {
@Autowired
private ImageService imageService;
//md编辑器的图片上传表单的name参数值
@Value("${Md_Editor_imageFile_name}")
private String Md_Editor_imageFile_name;
//是否允许服务修改上传到服务器后的文件名
@Value("${ImageUploadService_isAllownRename}")
private boolean ImageUploadService_isAllownRename;
//md图片上传以及回显
@RequestMapping(value = "/imageUpload")
@ResponseBody
public Md_ImageUpResultDTO uploadImage(HttpServletRequest request){
//转换request
MultipartHttpServletRequest multipartRequest= (MultipartHttpServletRequest) request;
String downloadUrl="";
try {
//需要md图片表单提交的文件name
MultipartFile file = multipartRequest.getFile(Md_Editor_imageFile_name);
if (file != null) {
InputStream inputStream = file.getInputStream();
String contentType = file.getContentType();
String filename = file.getOriginalFilename();
//调用上传服务
downloadUrl = imageService.upload(inputStream, contentType, ImageUploadService_isAllownRename, filename);
}
} catch (IOException e) {
e.printStackTrace();
}
return new Md_ImageUpResultDTO(1,"上传成功!",downloadUrl);
}
}
Service
package cn.shirtiny.community.SHcommunity.Service.ServiceImpl;
import ...
@Service
public class ImageServiceImpl implements ImageService {
//公钥
@Value("${ucloud_uFile_SHtoken_PublicKey}")
private String myPublicKey;
//私钥
@Value("${ucloud_uFile_SHtoken_PrivateKey}")
private String myPrivateKey;
//bucket地域
@Value("${ucloud_uFile_bucket_region}")
private String region;
//域名后缀
@Value("${ucloud_uFile_bucket_proxySuffix}")
private String proxySuffix;
//名字
@Value("${ucloud_uFile_bucket_name}")
private String bucketName;
//临时下载地址的过期时间
// 2 * 60秒 --> 2分钟后过期,315360000 --> 10 * 365 * 24 * 60 * 60 = 10年
@Value("${ucloud_uFile_downloadURL_expiresDuration}")
private int expiresDuration;
/**上传文件
* @param inputStream 文件的流
* @param mimeType 文件的ContentType
* @param allownRename 是否需要、允许修改文件上传到服务器后的名字
* @param clientFileName 初始文件名
* @return downLoadUrl 返回刚刚上传文件的临时地址
*/
@Override
public String upload(InputStream inputStream, String mimeType, boolean allownRename, String clientFileName) {
//文件上传到服务器后的名字
String serverFileName = clientFileName;
//临时下载地址
String downloadUrl = "";
//当允许重命名文件时,命名文件
if (allownRename) {
System.out.println("暂时先不重命名,到时候看一下id生成工具"+"emm百度开源的那个雪花算法的uidGenerator要用到数据库");
//暂时用以前自己写的,传到服务器后的文件名
serverFileName=createRandomName(clientFileName);
}
// 对象相关API的授权器
ObjectAuthorization OBJECT_AUTHORIZER = new UfileObjectLocalAuthorization(myPublicKey, myPrivateKey);
// 对象操作需要ObjectConfig来配置您的地区和域名后缀
ObjectConfig config = new ObjectConfig(region, proxySuffix);
//待上传文件
//File file = new File("your file path");
PutObjectResultBean response;
{
try {
response = UfileClient.object(OBJECT_AUTHORIZER, config)
//可以使用文件的方式
.putObject(inputStream, mimeType)
.nameAs(serverFileName)
//我命名空间的名字
.toBucket(bucketName)
/**
* 是否上传校验MD5, Default = true
*/
// .withVerifyMd5(false)
/**
* 指定progress callback的间隔, Default = 每秒回调
*/
// .withProgressConfig(ProgressConfig.callbackWithPercent(10))
/**
* 配置进度监听
*/
.setOnProgressListener(new OnProgressListener() {
@Override
public void onProgress(long bytesWritten, long contentLength) {
//已上传/总长度
System.out.println(bytesWritten + "/" + contentLength + "进度:" + (bytesWritten * 100) / contentLength + "%");
}
}).execute();
//上传完成后,查看response,然后获得刚刚上传图片的临时地址
//上传成功RetCode是0,错误时的response:{"ResponseCode":400,"RetCode":-30010,"ErrMsg":"bucket not exist","X-SessionId":"0e9df91b-5d69-4e9b-bfeb-5d9b8c182869"}
if (response.getRetCode() == 0) {
//获取刚刚上传的文件地址,设置过期时间
downloadUrl = UfileClient.object(OBJECT_AUTHORIZER, config)
.getDownloadUrlFromPrivateBucket(serverFileName, bucketName, expiresDuration)
.createUrl();
return downloadUrl;
}
} catch (UfileClientException | UfileServerException e) {
throw new Md_ImageUploadFailedException(e.getMessage());
}
}
return downloadUrl;
}
@Override
public String createRandomName(String clientFileName) {
//拿到文件后缀名
String suffix = clientFileName.substring(clientFileName.lastIndexOf("."));
//根据当前日期,伪建一个文件夹
String directory = new SimpleDateFormat("yyyy-MM-dd").format(new Date());
//生成新的文件名
//时间戳
long currentTimeMillis = System.currentTimeMillis();
//随机数
Random random = new Random();
int randomInt = random.nextInt(999);
//%X 获得数字,把它转为16进制,大写字母
//%04X 增加的04,意思是,转化后的字符串占4个字符,不够用0填充
String fileId=currentTimeMillis+String.format("%04X",randomInt);
//组合为传到服务器后的文件名
return directory+"_"+fileId+suffix;
}
}
4. Editor.md 拓展
Md编辑器内容提交、显示,VueJs+Element组合的简单使用。
提交编辑的内容
<div class="col-xs-12 col-sm-9 row_left" id="vue_Editor">
<!-- 编辑文章标题 -->
<div class="input-group input-group-lg margin_top">
<span class="input-group-addon" id="sizing-addon1">title</span>
<input
v-model="md_title"
class=" form-control"
type="text"
placeholder="在此输入标题..."
aria-describedby="sizing-addon1"
name="title"
/>
</div>
<hr />
<!--编辑文章内容-->
<h2><label for="content">Content</label></h2>
<!--md编辑器-->
<div id="editor" class="sh_MdEditor">
<textarea
style="display:none;"
name="content"
id="content"
placeholder="在此输入文章内容..."
></textarea>
</div>
<!--空文本错误警告-->
<!--提交按钮 -->
<button type="button" class="btn btn-success btn-lg float_right" @click="submitMd">发布</button>
</div>
<!--md编辑-->
<script type="text/javascript">
$(function () {
const vue_Editor = new Vue({
el: '#vue_Editor',
data: {
md_title: '',
fileUploadErr: false,
},
methods: {
submitMd: function () {
//获得编辑区Markdown源码
var md_content = editor.getMarkdown();
console.log('输出title:\n' + vue_Editor.md_title);
console.log('输出content:\n' + md_content);
//提交数据给后台
axios
.post('/createInvitation', {
title: vue_Editor.md_title,
content: md_content,
isAxios: true,
})
.then(function (response) {
//成功提交的情况
if (response.data.code == 200) {
//通知
vue_Editor.$notify({
title: 'OK~',
message: response.data.message + ',即将跳转...',
type: 'success',
});
//2秒后调到最后一页
setTimeout(function () {
window.location.href = '/?curPage=999999';
}, 2000);
} else if (response.data.code == 400) {
vue_Editor.$notify.error({
title: 'No~',
message: response.data.message,
});
}
})
.catch(function (error) {
this.$alert(error, '服务器出错', {
confirmButtonText: '确定',
});
console.log(error);
});
},
},
});
var editor = editormd('editor', {
width: '100%',
height: '100%',
// markdown: "xxxx", // dynamic set Markdown text
path: '/editormd/lib/', // Autoload modules mode, codemirror, marked... dependents libs path
delay: 0,
codeFold: true,
htmlDecode: true,
emoji: true,
//图片上传
imageUpload: true,
imageFormats: ['jpg', 'jpeg', 'gif', 'png', 'bmp', 'webp'],
imageUploadURL: '/imageUpload', // Upload url
crossDomainUpload: false, // Enable/disable Cross-domain upload
uploadCallbackURL: '',
placeholder: '在此输入文章内容,使用markdown语法...',
description: 'Markdown 文本编辑',
lang: {
// Language data, you can custom your language.
description: 'Markdown编辑器<br/>Markdown editor.',
},
});
});
</script>
Controller
@Controller
public class InvitationController {
@Autowired
private IinvitationService invitationService;
@PostMapping(value = "/createInvitation")
@ResponseBody
public ShResultDTO createInvitation(@RequestBody Invitation invitation, Model model, HttpServletRequest request){
Long userId=((User)request.getSession().getAttribute("user")).getId();
invitation.setAuthorId(userId);
boolean flag = invitationService.addInvitation(invitation);
if(flag){
return new ShResultDTO<String>(200,"提交成功了哦~");
}else {
return new ShResultDTO<String>(400,"标题或内容不能为空,并且字数不能大于20和400");
}
}
其中ShResultDTO为返回信息的封装
package cn.shirtiny.community.SHcommunity.DTO;
import lombok.Data;
@Data
public class ShResultDTO<T> {
//状态码
private Integer code;
//信息
private String message;
//数据
private T data;
//错误
String error;
public ShResultDTO(Integer code, String message) {
this.code = code;
this.message = message;
}
public ShResultDTO(String message, String error) {
this.message = message;
this.error = error;
}
}
Service
接口省略...
@Service
@Transactional
public class InvitationService implements IinvitationService {
@Autowired
private InvitationMapper invitationMapper;
//增加帖子
@Override
public boolean addInvitation(Invitation invitation) {
if (invitation == null) {
return false;
}
boolean titleIsEmpty = StringUtils.isEmpty(invitation.getTitle());
boolean contentIsEmpty = StringUtils.isEmpty(invitation.getContent());
//判断标题或内容是不是空、标题或内容长度是否超限
if (titleIsEmpty || contentIsEmpty || invitation.getTitle().length() > 20 || invitation.getContent().length() > 2000) {
return false;
} else {
invitation.setGmtCreated(System.currentTimeMillis());
invitation.setGmtModified(invitation.getGmtCreated());
try {
invitationMapper.insert(invitation);//插入数据库
return true;
} catch (Exception e) {
throw new CreateInvitationErrException(e.toString(),4502);
}
}
}
其中CreateInvitationErrExceptio为自定义异常类
全局异常处理
@ControllerAdvice//结合@ExceptionHandler用于全局异常的处理
public class myControllerAdvice {
@ExceptionHandler(CreateInvitationErrException.class)
@ResponseBody
public ShResultDTO createInvitationErr(Throwable e){
System.out.println("帖子提交失败,数据库的异常,在应该是InvitationService里抛出");
return new ShResultDTO(4502,e.getMessage());
}
}
在Vue对象中对Markdown的解析
<!--md查看器-->
<div id="md_viewer" class="sh_MdViewer">
<textarea style="display:none;"></textarea>
</div>
<!--vueJs-->
<script>
const vue_invitationDetail_paper = new Vue({
el: "#vue_invitationDetail_paper",
data: {
//不能用数字,js精度不够
invitationId: '',
//帖子对象
invitationDetail: {},
//评论数组
comments:[],
user: {},
//未发送的评论内容
commentContent: '',
},
created: function () {
//获取帖子id
this.getInvitationIdFromUrl();
console.log("拿到的帖子id为:" + this.invitationId);
//调用api,初始化数据
axios.get('/shApi/invitationDetail/' + this.invitationId).then(res => {
console.log("获取到的数据:");
this.invitationDetail = res.data.data.invitationDetail;
this.user = res.data.data.user;
this.comments=res.data.data.invitationDetail.comments;
//帖子内容
let content = res.data.data.invitationDetail.content;
console.log("帖子内容" + content);
//把帖子内容解析为markdown
editormd.markdownToHTML("md_viewer", {
markdown: content, //这里动态的设置md内容
htmlDecode: true // Enable / disable HTML tag encode.
});
});
}
})
</script>