Merge branch 'blossom-editor:dev' into dev

This commit is contained in:
Tianjiu 2024-01-28 10:46:35 +08:00 committed by GitHub
commit 1c59f0ae08
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
68 changed files with 3203 additions and 669 deletions

View File

@ -73,18 +73,11 @@ docker compose -f docker/compose/blossom-mysql8.yaml up -d
**感谢每一个位赞助者对 Blossom 的大力支持Blossom 因为你们变得更好。**
| 赞助者 / Sponsors | 赞助者 / Sponsors | 赞助者 / Sponsors | 赞助者 / Sponsors |
| :------------------- | :---------------- | :---------------- | :---------------- |
| i just walked away🏃 | KalyanYang | dsx | 熊猫不是猫 |
| 白驹过隙 | 支付宝用户-\*发 | 支付宝用户-\*琛 | 支付宝匿名用户 |
| 疾风剑豪索隆 | 放羊的星星 | 立冬 | 给你一支烟 |
| 林诗 | Criesgod | 支付宝用户-\*伟 | 黑醋栗和丁香 |
| Mr_tg000 | Lucky | egil | Glimpse |
| 支付宝用户-\*\*衡 | 支付宝用户-\*\*福 | 支付宝用户-\*\*盼 | 何其正 |
| -A 明 | 开心。 | Please | 樹梢 |
| 支付宝用户-\*\*耀 | Crius | 初五 | 一巷灯火深 |
| 支付宝用户-\*\*鲲 | 木辰音 | 宁采臣 | 支付宝用户-\*\*雷 |
| Klaus | | | |
<p align="center">
<a target="_blank" href="https://www.wangyunf.com/blossom-doc/guide/about/sponsor-list.html">
<img alt="sponsors" src="https://www.wangyunf.com/bl/pic/home/bl/img/U1/pic/sponsor.svg">
</a>
</p>
---

View File

@ -2,15 +2,8 @@
感谢每一个位赞助者对 Blossom 的大力支持Blossom 因为你们变得更好。
| 赞助者 / Sponsors | 赞助者 / Sponsors | 赞助者 / Sponsors | 赞助者 / Sponsors |
| :------------------- | :---------------- | :---------------- | :---------------- |
| i just walked away🏃 | KalyanYang | dsx | 熊猫不是猫 |
| 白驹过隙 | 支付宝用户-\*发 | 支付宝用户-\*琛 | 支付宝匿名用户 |
| 疾风剑豪索隆 | 放羊的星星 | 立冬 | 给你一支烟 |
| 林诗 | Criesgod | 支付宝用户-\*伟 | 黑醋栗和丁香 |
| Mr_tg000 | Lucky | egil | Glimpse |
| 支付宝用户-\*\*衡 | 支付宝用户-\*\*福 | 支付宝用户-\*\*盼 | 何其正 |
| -A 明 | 开心。 | Please | 樹梢 |
| 支付宝用户-\*\*耀 | Crius | 初五 | 一巷灯火深 |
| 支付宝用户-\*\*鲲 | 木辰音 | 宁采臣 | 支付宝用户-\*\*雷 |
| Klaus | | | |
<p align="center">
<a target="_blank" href="https://www.wangyunf.com/blossom-doc/guide/about/sponsor-list.html">
<img alt="sponsors" src="https://www.wangyunf.com/bl/pic/home/bl/img/U1/pic/sponsor.svg">
</a>
</p>

View File

@ -47,7 +47,7 @@ public enum ParamEnum {
/**
* BLOSSOM 对象存储地址
*/
BLOSSOM_OBJECT_STORAGE_DOMAIN(false, 0,"http://www.xxx.com/"),
BLOSSOM_OBJECT_STORAGE_DOMAIN(false, 0,"http://www.google.com/"),
/**
* 服务器JWT加密字符串

View File

@ -41,7 +41,7 @@ public class PropertiesCheckListener implements ApplicationListener<ApplicationE
"\n[CHECK] 数据库配置: {}" +
"\n[CHECK] 数据库用户: {}" +
"\n[CHECK] 数据库密码: {}" +
"\n[CHECK] 文件前缀: {}, 非 [http://www.xxx.com/] 内容会强制覆盖后台配置" +
"\n[CHECK] 文件前缀: {}, 非 [http://www.google.com/] 内容会强制覆盖后台配置" +
"\n[CHECK] 文件存储: {}" +
"\n[CHECK] 文件大小: {}" +
"\n[CHECK] 授权时长: {}" +

View File

@ -139,6 +139,7 @@ public class ArticleController {
public R<Long> insert(@Validated @RequestBody ArticleUpdReq req) {
ArticleEntity article = req.to(ArticleEntity.class);
article.setTags(DocUtil.toTagStr(req.getTags()));
article.setUserId(AuthContext.getUserId());
return R.ok(baseService.update(article));
}
@ -166,6 +167,7 @@ public class ArticleController {
@PostMapping("/upd/name")
public R<?> updName(@Validated @RequestBody ArticleUpdNameReq name) {
ArticleEntity article = name.to(ArticleEntity.class);
article.setUserId(AuthContext.getUserId());
baseService.update(article);
return R.ok();
}
@ -187,6 +189,7 @@ public class ArticleController {
}
ArticleEntity article = req.to(ArticleEntity.class);
article.setTags(DocUtil.toTagStr(tags));
article.setUserId(AuthContext.getUserId());
baseService.update(article);
return R.ok(tags);
}
@ -207,7 +210,9 @@ public class ArticleController {
*/
@PostMapping("/star")
public R<Long> star(@Validated @RequestBody ArticleStarReq req) {
return R.ok(baseService.update(req.to(ArticleEntity.class)));
ArticleEntity article = req.to(ArticleEntity.class);
article.setUserId(AuthContext.getUserId());
return R.ok(baseService.update(article));
}
/**
@ -221,12 +226,11 @@ public class ArticleController {
public void download(@RequestParam("id") Long id, HttpServletResponse response) throws IOException {
ArticleEntity article = baseService.selectById(id, false, true, false, AuthContext.getUserId());
if (StrUtil.isBlank(article.getMarkdown())) {
throw new IllegalArgumentException("文章内容为空,无法导出");
article.setMarkdown("文章无内容");
}
try (InputStream is = new ByteArrayInputStream(article.getMarkdown().getBytes(StandardCharsets.UTF_8));
BufferedInputStream bis = new BufferedInputStream(is)) {
String filename = URLEncodeUtil.encode(article.getName() + ".md");
DownloadUtil.forceDownload(response, bis, filename);
}
}
@ -242,7 +246,7 @@ public class ArticleController {
public void downloadHtml(@RequestParam("id") Long id, HttpServletResponse response) throws IOException {
ArticleEntity article = baseService.selectById(id, false, false, true, AuthContext.getUserId());
if (StrUtil.isBlank(article.getHtml())) {
throw new IllegalArgumentException("文章内容为空,无法导出");
article.setHtml("<span>文章无内容</span>");
}
String reportHtml = ArticleUtil.toHtml(article, userService.selectById(AuthContext.getUserId()));
try (InputStream is = new ByteArrayInputStream(reportHtml.getBytes(StandardCharsets.UTF_8));
@ -270,9 +274,11 @@ public class ArticleController {
String content = new String(file.getBytes(), StandardCharsets.UTF_8);
ArticleEntity article = new ArticleEntity();
article.setMarkdown(content);
article.setVersion(1);
article.setPid(pid);
article.setUserId(AuthContext.getUserId());
article.setName(FileUtil.getPrefix(file.getOriginalFilename()));
article.setWords(ArticleUtil.statWords(content));
baseService.insert(article);
} catch (Exception e) {
e.printStackTrace();

View File

@ -166,6 +166,7 @@ public class ArticleService extends ServiceImpl<ArticleMapper, ArticleEntity> {
public Long update(ArticleEntity req) {
XzException404.throwBy(req.getId() == null, "ID不得为空");
baseMapper.updById(req);
referenceService.updateInnerName(req.getUserId(), req.getId(), req.getName());
return req.getId();
}
@ -212,8 +213,10 @@ public class ArticleService extends ServiceImpl<ArticleMapper, ArticleEntity> {
baseMapper.deleteById(id);
// 删除公开文章
openMapper.delById(id);
// 删除引用
// 删除主动引用
referenceService.delete(id);
// 将被动引用中的名称修改为未知
referenceService.updateToUnknown(userId, id);
// 删除访问记录
viewService.delete(id);
}

View File

@ -1,23 +1,33 @@
package com.blossom.backend.server.article.recycle;
import cn.hutool.core.net.URLEncodeUtil;
import cn.hutool.core.util.StrUtil;
import com.blossom.backend.base.auth.AuthContext;
import com.blossom.backend.server.article.recycle.pojo.ArticleRecycleEntity;
import com.blossom.backend.server.article.recycle.pojo.ArticleRecycleListRes;
import com.blossom.backend.server.article.recycle.pojo.ArticleRecycleRestoreReq;
import com.blossom.backend.server.utils.DownloadUtil;
import com.blossom.common.base.pojo.R;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletResponse;
import java.io.BufferedInputStream;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.List;
/**
* 文章回收站 [A#Recycle]
*
* @author xzzz
* @since 1.10.0
* @order 7
* @since 1.10.0
*/
@Slf4j
@RestController
@ -44,7 +54,27 @@ public class ArticleRecycleController {
*/
@PostMapping("/restore")
public R<?> restore(@Validated @RequestBody ArticleRecycleRestoreReq req) {
baseService.restore(req.getId());
baseService.restore(AuthContext.getUserId(), req.getId());
return R.ok();
}
/**
* 下载文章
*
* @param id 文章ID
* @param response 文章流
* @apiNote 返回流
*/
@GetMapping("/download")
public void download(@RequestParam("id") Long id, HttpServletResponse response) throws IOException {
ArticleRecycleEntity article = baseService.selectById(id);
if (StrUtil.isBlank(article.getMarkdown())) {
article.setMarkdown("文章无内容");
}
try (InputStream is = new ByteArrayInputStream(article.getMarkdown().getBytes(StandardCharsets.UTF_8));
BufferedInputStream bis = new BufferedInputStream(is)) {
String filename = URLEncodeUtil.encode(article.getName() + ".md");
DownloadUtil.forceDownload(response, bis, filename);
}
}
}

View File

@ -9,6 +9,7 @@ import com.blossom.backend.base.param.pojo.ParamEntity;
import com.blossom.backend.base.search.EnableIndex;
import com.blossom.backend.base.search.message.IndexMsgTypeEnum;
import com.blossom.backend.server.article.recycle.pojo.ArticleRecycleEntity;
import com.blossom.backend.server.article.reference.ArticleReferenceService;
import com.blossom.backend.server.folder.FolderService;
import com.blossom.backend.server.folder.pojo.FolderEntity;
import com.blossom.common.base.util.DateUtils;
@ -34,6 +35,7 @@ public class ArticleRecycleService extends ServiceImpl<ArticleRecycleMapper, Art
private final FolderService folderService;
private final ParamService paramService;
private final ArticleReferenceService referenceService;
/**
@ -45,6 +47,15 @@ public class ArticleRecycleService extends ServiceImpl<ArticleRecycleMapper, Art
return baseMapper.listAll(userId);
}
/**
* 根据ID查询
*
* @param id 文章ID
*/
public ArticleRecycleEntity selectById(Long id) {
return baseMapper.selectById(id);
}
/**
* 还原数据
*
@ -52,7 +63,7 @@ public class ArticleRecycleService extends ServiceImpl<ArticleRecycleMapper, Art
*/
@EnableIndex(type = IndexMsgTypeEnum.ADD, id = "#id")
@Transactional(rollbackFor = Exception.class)
public void restore(Long id) {
public void restore(Long userId, Long id) {
ArticleRecycleEntity article = baseMapper.selectById(id);
FolderEntity folder = folderService.selectById(article.getPid());
if (ObjUtil.isNull(folder)) {
@ -61,10 +72,13 @@ public class ArticleRecycleService extends ServiceImpl<ArticleRecycleMapper, Art
baseMapper.restore(id, folder.getId());
}
baseMapper.deleteById(id);
// 将被动引用中的未知文章名修改为正常文章名
referenceService.updateToKnown(userId, id, article.getName());
}
/**
* 每天凌晨4点执行
*
* @Scheduled(cron = "0 0/1 * * * ?")
*/
@Scheduled(cron = "0 0 04 * * ?")

View File

@ -0,0 +1,30 @@
package com.blossom.backend.server.article.reference;
import lombok.Getter;
public enum ArticleReferenceEnum {
/**
* 图片
*/
FILE(10),
/**
* 内部文章
*/
INNER(11),
/**
* 未知内部文章
*/
INNER_UNKNOWN(12),
/**
* 外部文章
*/
OUTSIDE(21);
@Getter
private final Integer type;
ArticleReferenceEnum(Integer type) {
this.type = type;
}
}

View File

@ -40,4 +40,25 @@ public interface ArticleReferenceMapper extends BaseMapper<ArticleReferenceEntit
* @param articleId 文章ID
*/
List<ArticleReferenceEntity> listGraph(@Param("inner") Boolean inner, @Param("userId") Long userId, @Param("articleId") Long articleId);
/**
* 修改 sourceName
*/
void updateSourceName(@Param("userId") Long userId, @Param("sourceId") Long sourceId, @Param("sourceName") String sourceName);
/**
* 修改 targetName
*/
void updateTargetName(@Param("userId") Long userId, @Param("targetId") Long targetId, @Param("targetName") String targetName);
/**
* 被引用的文章修改为未知
*/
void updateToUnknown(@Param("userId") Long userId, @Param("targetId") Long targetId, @Param("targetName") String targetName);
/**
* 被引用的文章修改为未知
*/
void updateToKnown(@Param("userId") Long userId, @Param("targetId") Long targetId, @Param("targetName") String targetName);
}

View File

@ -1,6 +1,7 @@
package com.blossom.backend.server.article.reference;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.blossom.backend.server.article.reference.pojo.ArticleReferenceEntity;
@ -29,11 +30,15 @@ public class ArticleReferenceService extends ServiceImpl<ArticleReferenceMapper,
/**
* 文章引用记录
*
* @param userId 用户ID
* @param sourceId 引用源
* @param sourceName 引用源名称
* @param references 目标
*/
@Transactional(rollbackFor = Exception.class)
public void bind(Long userId, Long sourceId, String sourceName, List<ArticleReferenceReq> references) {
delete(sourceId);
// 没有图片, 则不保存
if (CollUtil.isEmpty(references)) {
return;
@ -47,11 +52,51 @@ public class ArticleReferenceService extends ServiceImpl<ArticleReferenceMapper,
baseMapper.insertList(refs);
}
/**
* 将文章修改为内部未知文章
*
* @param userId 用户ID
* @param targetId 目标文章ID
*/
@Transactional(rollbackFor = Exception.class)
public void updateToUnknown(Long userId, Long targetId) {
baseMapper.updateToUnknown(userId, targetId, "未知文章-" + targetId);
}
/**
* 将文章引用修改为内部具名文章
*
* @param userId 用户ID
* @param targetId 目标文章ID
* @param name 文章名称
*/
@Transactional(rollbackFor = Exception.class)
public void updateToKnown(Long userId, Long targetId, String name) {
baseMapper.updateToKnown(userId, targetId, name);
}
/**
* 内部链接修改名称时, 同时修改双链中的名称
*
* @param articleId ID
* @param name 名称
*/
@Transactional(rollbackFor = Exception.class)
public void updateInnerName(Long userId, Long articleId, String name) {
if (articleId <= 0 || StrUtil.isBlank(name)) {
return;
}
baseMapper.updateSourceName(userId, articleId, name);
baseMapper.updateTargetName(userId, articleId, name);
}
/**
* 删除引用
*
* @param articleId 文章ID
*/
@Transactional(rollbackFor = Exception.class)
public void delete(Long articleId) {
LambdaQueryWrapper<ArticleReferenceEntity> where = new LambdaQueryWrapper<>();
where.eq(ArticleReferenceEntity::getSourceId, articleId);
@ -89,6 +134,8 @@ public class ArticleReferenceService extends ServiceImpl<ArticleReferenceMapper,
Map<String, Object> result = new HashMap<>();
List<ArticleReferenceEntity> all = baseMapper.listGraph(onlyInner, userId, articleId);
if (CollUtil.isEmpty(all)) {
result.put("nodes",new String[0]);
result.put("links",new String[0]);
return result;
}
@ -100,14 +147,15 @@ public class ArticleReferenceService extends ServiceImpl<ArticleReferenceMapper,
Set<Node> nodes = new HashSet<>();
source.forEach((id, list) -> {
Node node = new Node(list.get(0).getSourceName(), 11);
Node node = new Node(list.get(0).getSourceName(), ArticleReferenceEnum.INNER.getType());
node.setInner(true);
node.setArtId(id);
nodes.add(node);
});
target.forEach((name, list) -> {
Node node = new Node(list.get(0).getTargetName(), list.get(0).getType());
if (list.get(0).getType().equals(11)) {
final Integer type = list.get(0).getType();
Node node = new Node(list.get(0).getTargetName(), type);
if (type.equals(ArticleReferenceEnum.INNER.getType()) || type.equals(ArticleReferenceEnum.INNER_UNKNOWN.getType())) {
node.setInner(true);
node.setArtId(list.get(0).getTargetId());
} else {

View File

@ -41,19 +41,34 @@ public class TodoService extends ServiceImpl<TodoMapper, TodoEntity> {
public TodoGroupRes listTodo() {
List<TodoEntity> todos = baseMapper.listTodo(AuthContext.getUserId());
TodoGroupRes res = TodoGroupRes.build();
for (TodoEntity todo : todos) {
TodoGroupRes.TodoGroup group = todo.to(TodoGroupRes.TodoGroup.class);
if (TodoTypeEnum.DAY.getType().equals(todo.getTodoType())) {
Map<String, List<TodoEntity>> map = todos.stream().collect(Collectors.groupingBy(TodoEntity::getTodoId));
map.forEach((todoId, data) -> {
TodoGroupRes.TodoGroup group = data.get(0).to(TodoGroupRes.TodoGroup.class);
Map<String, List<TodoEntity>> taskStatusMap = data.stream().collect(Collectors.groupingBy(TodoEntity::getTaskStatus));
int w = 0, p = 0, c = 0;
if (CollUtil.isNotEmpty(taskStatusMap.get(TaskStatusEnum.WAITING.name()))) {
w = taskStatusMap.get(TaskStatusEnum.WAITING.name()).get(0).getTaskCount();
}
if (CollUtil.isNotEmpty(taskStatusMap.get(TaskStatusEnum.PROCESSING.name()))) {
p = taskStatusMap.get(TaskStatusEnum.PROCESSING.name()).get(0).getTaskCount();
}
if (CollUtil.isNotEmpty(taskStatusMap.get(TaskStatusEnum.COMPLETED.name()))) {
c = taskStatusMap.get(TaskStatusEnum.COMPLETED.name()).get(0).getTaskCount();
}
group.setTaskCountStat(String.format("%d|%d|%d", w, p, c));
if (TodoTypeEnum.DAY.getType().equals(data.get(0).getTodoType())) {
res.getTodoDays().put(group.getTodoId(), group);
} else {
// 未完成的阶段性事项
if (TodoStatusEnum.OPEN.getType().equals(todo.getTodoStatus())) {
if (TodoStatusEnum.OPEN.getType().equals(data.get(0).getTodoStatus())) {
res.getTaskPhased().add(group);
} else {
res.getTaskPhasedClose().add(group);
}
}
}
});
return res;
}

View File

@ -55,6 +55,10 @@ public class TodoGroupRes {
* 任务数量
*/
private Integer taskCount;
/**
* 任务数量说明
*/
private String taskCountStat;
/**
* 事项状态 1:完成 | 2:未完成
*/

View File

@ -84,6 +84,6 @@ project:
os-type: blossom
blos:
# 请以 /pic 结尾, 如果你在 nginx 中配置有代理, 注意别忘了添加你的代理路径
domain: "http://www.xxx.com/"
domain: "http://www.google.com/"
# 请以 / 开头, / 结尾, 简短的路径在文章中有更好的显示效果, 过长一定程度会使文章内容混乱
default-path: "/home/bl/img/"

View File

@ -76,6 +76,6 @@ project:
blos:
# 请以 /pic 结尾, 如果你在 nginx 中配置有代理, 注意别忘了添加你的代理路径
# 注意:在下方示例中, /bl 即为 nginx 反向代理路径, 如果你的访问路径中不包含反向代理或路径不同, 请酌情删除或修改
domain: "http://www.xxx.com/"
domain: "http://www.google.com/"
# 请以 / 开头, / 结尾, 简短的路径在文章中有更好的显示效果, 过长一定程度会使文章内容混乱
default-path: "/home/bl/img/"

View File

@ -78,6 +78,7 @@
upd_time = now()
</set>
where id = #{id}
and user_id = #{userId}
</update>
<!-- 根据ID修改 -->

View File

@ -48,14 +48,43 @@
from blossom_article_reference
where user_id = #{userId}
<if test="inner == true">
and type = 11
and type in(11,12)
</if>
<if test="inner == false">
and type in (11,21)
and type in (11,12,21)
</if>
<if test="articleId != null">
and (source_id = #{articleId} or target_id = #{articleId})
</if>
</select>
<update id="updateSourceName">
update blossom_article_reference
set source_name = #{sourceName}
where source_id = #{sourceId}
and user_id = #{userId}
</update>
<update id="updateTargetName">
update blossom_article_reference
set target_name = #{targetName}
where target_Id = #{targetId}
and user_id = #{userId}
</update>
<update id="updateToUnknown">
update blossom_article_reference
set target_name = #{targetName},
type = 12
where target_Id = #{targetId}
and user_id = #{userId}
</update>
<update id="updateToKnown">
update blossom_article_reference
set target_name = #{targetName},
type = 11
where target_Id = #{targetId}
and user_id = #{userId}
</update>
</mapper>

View File

@ -47,12 +47,12 @@
</select>
<select id="listTodo" resultType="com.blossom.backend.server.todo.pojo.TodoEntity">
select min(id) as id,todo_id,todo_name,todo_type,todo_status,
select min(id) as id,todo_id,todo_name,todo_type,todo_status,task_status,
count(*) as taskCount
from blossom_todo
where user_id = #{userId}
group by todo_id,todo_name,todo_type,todo_status
order by id desc
from blossom_todo
where user_id = #{userId}
group by todo_id,todo_name,todo_type,todo_status,task_status
order by id desc
</select>
<select id="selectByTodoId" resultType="com.blossom.backend.server.todo.pojo.TodoEntity">

View File

@ -189,7 +189,7 @@ INSERT INTO base_sys_param (id, param_name, param_value, param_desc, open_state,
SELECT *
FROM (select 101 as id,
'BLOSSOM_OBJECT_STORAGE_DOMAIN' as param_name,
'http://www.xxx.com/' as param_value,
'http://www.google.com/' as param_value,
'BLOSSOM 对象存储地址' as param_desc,
1 as open_state,
CURRENT_TIMESTAMP as cre_time,

View File

@ -473,6 +473,11 @@ export const articleRecycleRestoreApi = (data?: object): Promise<any> => {
return rq.post('/article/recycle/restore', data)
}
export const articleRecycleDownloadApi = (params?: object): Promise<any> => {
let config = { params: params, responseType: 'blob' }
return rq.get('/article/recycle/download', config)
}
/**
*
*/

View File

@ -279,8 +279,8 @@ html.dark {
--bl-preview-pre-box-shadow: inset 0 0 8px #9e9e9e; */
/* 全局滚动条样式 */
--bl-scrollbar-color: #0000001a;
--bl-scrollbar-color-hover: #0000002a;
--bl-scrollbar-color: #bfbfbf66;
--bl-scrollbar-color-hover: #8484848a;
}
html.dark {

View File

@ -1,8 +1,8 @@
@font-face {
font-family: "iconbl"; /* Project id 4118609 */
src: url('iconfont.woff2?t=1704477350520') format('woff2'),
url('iconfont.woff?t=1704477350520') format('woff'),
url('iconfont.ttf?t=1704477350520') format('truetype');
src: url('iconfont.woff2?t=1705742748603') format('woff2'),
url('iconfont.woff?t=1705742748603') format('woff'),
url('iconfont.ttf?t=1705742748603') format('truetype');
}
.iconbl {
@ -13,6 +13,10 @@
-moz-osx-font-smoothing: grayscale;
}
.bl-scroll:before {
content: "\e610";
}
.bl-and:before {
content: "\e71b";
}

File diff suppressed because one or more lines are too long

View File

@ -5,6 +5,13 @@
"css_prefix_text": "bl-",
"description": "",
"glyphs": [
{
"icon_id": "9691336",
"name": "滚轮滚动",
"font_class": "scroll",
"unicode": "e610",
"unicode_decimal": 58896
},
{
"icon_id": "8990451",
"name": "相交",

View File

@ -58,4 +58,4 @@
&-bottom:hover:after {
margin-top: 9px;
}
}
}

View File

@ -117,3 +117,7 @@
}
}
}
.el-popper.is-small {
padding: 0 8px;
}

View File

@ -36,17 +36,19 @@
ref="ButtonRef"
v-click-outside="onClickOutside"></div>
<div class="iconbl bl-a-colorpalette-line" @click="themeStrore.show()"></div>
<el-tooltip content="主题配置" popper-class="is-small" transition="none" effect="light" placement="top" :show-after="0" :hide-after="0">
<div class="iconbl bl-a-colorpalette-line" @click="themeStrore.show()"></div>
</el-tooltip>
<el-tooltip content="查看图标" effect="light" placement="top" :show-after="1000" :hide-after="0" :auto-close="2000">
<el-tooltip content="所有图标" popper-class="is-small" transition="none" effect="light" placement="top" :show-after="0" :hide-after="0">
<div class="iconbl bl-a-radiochoose-line" @click="toRoute('/iconListIndex')"></div>
</el-tooltip>
<el-tooltip content="显示网页收藏" effect="light" placement="top" :show-after="1000" :hide-after="0" :auto-close="2000">
<el-tooltip content="网页收藏" popper-class="is-small" transition="none" effect="light" placement="top" :show-after="0" :hide-after="0">
<div class="iconbl bl-folding-line" @click="isShowWebDrawer = !isShowWebDrawer"></div>
</el-tooltip>
<el-tooltip content="最佳窗口大小" effect="light" placement="top" :show-after="1000" :hide-after="0" :auto-close="2000">
<el-tooltip content="最佳窗口大小" popper-class="is-small" transition="none" effect="light" placement="top" :show-after="0" :hide-after="0">
<div v-if="isElectron()" :class="['iconbl bl-computer-line', isWindows() ? '' : 'electron-mac-last']" @click="setBestSize"></div>
</el-tooltip>

View File

@ -20,7 +20,7 @@ const DEFAULT_WEB_ARTICLE_URL = 'https://www.domain.com/blossom/#/articles?artic
/**
*
*/
const DEFAULT_BLOSSOM_OBJECT_STORAGE_DOMAIN = 'http://www.xxx.com/'
const DEFAULT_BLOSSOM_OBJECT_STORAGE_DOMAIN = 'http://www.google.com/'
/**
*

View File

@ -53,11 +53,48 @@
<div v-if="!curArticle" class="ep-placeholder">
<ArticleIndexPlaceholder></ArticleIndexPlaceholder>
</div>
<div class="operator" ref="EditorOperatorRef">
<el-tooltip
:content="'同步滚动:' + (editorOperator.sycnScroll ? '开启' : '关闭')"
popper-class="is-small"
effect="light"
placement="right"
transition="none"
:show-after="500"
:hide-after="0"
:show-arrow="false">
<div
class="iconbl bl-scroll"
:style="{ color: editorOperator.sycnScroll ? 'var(--el-color-primary-light-3)' : '' }"
@click="handleSyncScroll"></div>
</el-tooltip>
<el-tooltip
content="前往顶部"
popper-class="is-small"
effect="light"
placement="right"
transition="none"
:show-after="500"
:hide-after="0"
:show-arrow="false">
<div class="iconbl bl-a-doubleonline-line" @click="scrollTop"></div>
</el-tooltip>
<el-tooltip
content="前往底部"
popper-class="is-small"
effect="light"
placement="right"
transition="none"
:show-after="500"
:hide-after="0"
:show-arrow="false">
<div class="iconbl bl-a-doubleunderline-line" @click="scrollBottom"></div>
</el-tooltip>
</div>
<div class="gutter-holder" ref="GutterHolderRef"></div>
<div class="editor-codemirror" ref="EditorRef" @click.right="handleEditorClickRight"></div>
<div class="resize-divider" ref="ResizeDividerRef"></div>
<div class="preview-marked bl-preview" ref="PreviewRef" v-html="articleHtml"></div>
<el-backtop target=".editor-codemirror" :right="50" :bottom="70"> <div class="iconbl bl-send-line backtop"></div> </el-backtop>
</div>
<!-- status -->
@ -69,12 +106,10 @@
<div :class="['bl-preview-toc-absolute', tocsExpand ? 'is-expand-open' : 'is-expand-close']" ref="TocRef">
<div class="toc-title" ref="TocTitleRef">
目录
<span v-show="tocsExpand" style="font-size: 10px">({{ isMacOS() ? 'Cmd' : 'Alt' }}+2 可隐藏)</span>
<span v-show="tocsExpand" style="font-size: 10px">({{ keymaps.hideToc }} 可隐藏)</span>
</div>
<div class="toc-content" v-show="tocsExpand">
<div v-for="toc in articleToc" :key="toc.index" :class="[toc.clazz]" @click="toScroll(toc.level, toc.content)">
<span v-html="toc.content"></span>
</div>
<div v-for="toc in articleToc" :key="toc.id" :class="[toc.clazz]" @click="toScroll(toc.id)" v-html="toc.content"></div>
</div>
<div class="img-title">
引用图片
@ -156,39 +191,33 @@ import { articleInfoApi, articleUpdContentApi, uploadFileApiUrl } from '@rendere
// utils
import { Local } from '@renderer/assets/utils/storage'
import { isBlank, isNull } from '@renderer/assets/utils/obj'
import { sleep, isMacOS, isElectron } from '@renderer/assets/utils/util'
import { sleep, isElectron } from '@renderer/assets/utils/util'
import { openExtenal, writeText, readText, openNewArticleWindow } from '@renderer/assets/utils/electron'
import { formartMarkdownTable } from '@renderer/assets/utils/format-table'
// component
// import PictureViewerInfo from '@renderer/views/picture/PictureViewerInfo.vue'
import ArticleTreeDocs from './ArticleTreeDocs.vue'
import ArticleIndexPlaceholder from './ArticleIndexPlaceholder.vue'
import EditorTools from './EditorTools.vue'
// import EditorStatus from './EditorStatus.vue'
// ts
import hotkeys from 'hotkeys-js'
import Notify from '@renderer/scripts/notify'
import { useDraggable } from '@renderer/scripts/draggable'
import type { shortcutFunc } from '@renderer/scripts/shortcut-register'
import { treeToInfo, provideKeyDocInfo, provideKeyCurArticleInfo } from '@renderer/views/doc/doc'
import { TempTextareaKey, ArticleReference, DocEditorStyle } from './scripts/article'
import { treeToInfo, provideKeyDocInfo, provideKeyCurArticleInfo, isArticle } from '@renderer/views/doc/doc'
import { TempTextareaKey, ArticleReference, DocEditorStyle, parseTocAsync } from './scripts/article'
import type { Toc } from './scripts/article'
import { beforeUpload, onError, picCacheWrapper, picCacheRefresh, uploadForm, uploadDate } from '@renderer/views/picture/scripts/picture'
import { useResize } from './scripts/editor-preview-resize'
// codemirror
import { CmWrapper } from './scripts/codemirror'
// marked
import marked, {
renderBlockquote,
renderCode,
renderCodespan,
renderHeading,
renderImage,
renderTable,
tokenizerCodespan,
renderLink
} from './scripts/markedjs'
import marked, { renderBlockquote, renderCode, renderCodespan, renderHeading, renderImage, renderTable, renderLink } from './scripts/markedjs'
import { EPScroll } from './scripts/editor-preview-scroll'
import { useArticleHtmlEvent } from './scripts/article-html-event'
import { shallowRef } from 'vue'
import { keymaps } from './scripts/editor-tools'
//#region -- mounted
const PictureViewerInfo = defineAsyncComponent(() => import('@renderer/views/picture/PictureViewerInfo.vue'))
// const EditorTools = defineAsyncComponent(() => import('./EditorTools.vue'))
@ -223,7 +252,6 @@ onDeactivated(() => {
unbindKeys()
})
//#region ----------------------------------------< panin store >--------------------------------------
const userStore = useUserStore()
const serverStore = useServerStore()
const { editorStyle } = useConfigStore()
@ -236,14 +264,19 @@ watch(
setNewState('')
}
)
//#endregion
//#region ----------------------------------------< >--------------------------------------
const GutterHolderRef = ref() // editor gutter holder
const EditorRef = ref() // editor
const ResizeDividerRef = ref() // editor&preview resize dom
const EditorOperatorRef = ref()
const PreviewRef = ref() // html
const editorOperator = ref({
syncParse: true,
sycnScroll: true
})
/**
* 文档列表的展开和收起
*/
@ -266,7 +299,8 @@ const changeEditorPreviewStyle = () => {
GutterHolderRef.value.style.width = '0px'
EditorRef.value.style.width = '0px'
PreviewRef.value.style.width = '100%'
PreviewRef.value.style.padding = '10px 20px 0 20px'
PreviewRef.value.style.padding = '10px 20px 0 30px'
EditorOperatorRef.value.style.display = 'none'
return
}
if (editorFullScreen) {
@ -274,12 +308,15 @@ const changeEditorPreviewStyle = () => {
EditorRef.value.style.width = 'calc(100% - 6px)'
PreviewRef.value.style.width = '0'
PreviewRef.value.style.padding = '0'
EditorOperatorRef.value.style.display = 'none'
return
}
GutterHolderRef.value.style.width = '50px'
EditorRef.value.style.width = '50%'
PreviewRef.value.style.width = '50%'
PreviewRef.value.style.padding = '10px 20px 0 20px'
PreviewRef.value.style.padding = '10px 20px 0 30px'
EditorOperatorRef.value.style.display = 'block'
EditorOperatorRef.value.style.left = 'calc(50% - 0.5px)'
}
/**
* 临时文本框
@ -320,6 +357,7 @@ const exitView = () => {
autoSave()
}
useResize(EditorRef, PreviewRef, ResizeDividerRef, EditorOperatorRef)
//#endregion
//#region ----------------------------------------< >--------------------------------------
@ -354,6 +392,41 @@ const uploadFile = (file: File) => {
cmw.insertBlockCommand(`\n![${file.name}](${url})\n`)
})
}
/**
* 文件上传回调
* @param event DragEvent | ClipboardEvent
*/
const uploadFileCallback = async (event: DragEvent | ClipboardEvent) => {
if (!isArticle(curArticle.value)) return
/**
* 拖拽上传
*/
if (event instanceof DragEvent) {
let data: DataTransfer | null = event.dataTransfer
if (data && data.files.length && data.files.length > 0) {
for (const file of data.files) {
uploadFile(file)
}
}
}
/**
* 黏贴上传
*/
if (event instanceof ClipboardEvent) {
if (!event.clipboardData) return
if (event.clipboardData.items.length === 0) return
for (let i = 0; i < event.clipboardData.items.length; i++) {
const file: File | null = event.clipboardData.items[i].getAsFile()
if (file == null) {
return
}
uploadFile(file)
}
}
}
//#endregion
//#region ----------------------------------------< html >----------------------------
@ -401,7 +474,7 @@ const clickCurDoc = async (tree: DocTree) => {
// , ,
if (doc.type == 3) {
// ,
if (curIsArticle() && curArticle.value!.id == doc.id) {
if (isArticle(curArticle.value) && curArticle.value!.id == doc.id) {
return
}
editorLoadingTimeout = setTimeout(() => (editorLoading.value = true), 100)
@ -422,9 +495,7 @@ const clickCurDoc = async (tree: DocTree) => {
}
})
.finally(() => {
if (editorLoadingTimeout) {
clearTimeout(editorLoadingTimeout)
}
if (editorLoadingTimeout) clearTimeout(editorLoadingTimeout)
editorLoading.value = false
articleChanged = false
})
@ -439,22 +510,23 @@ const clickCurDoc = async (tree: DocTree) => {
* @param auto 是否为自动保存, 如果是自动保存, 则不弹出保存成功的提示框, 避免在非用户主动操作下弹框
*/
const saveCurArticleContent = async (auto: boolean = false) => {
if (!curIsArticle()) {
if (!isArticle(curArticle.value)) {
return
}
const saveCallback = () => {
if (!auto) {
ElMessage.success({ message: '保存成功', duration: 1000, offset: 70, grouping: true })
ElMessage.info({ message: '保存成功', duration: 1000, offset: 70, grouping: true })
}
}
//
if (!articleChanged) {
console.info('%c文档内容无变化, 无需保存', 'background:#AD8CF2;color:#fff;')
console.info('%c文档内容无变化, 无需保存', 'background:#AD8CF2;color:#fff;padding-top:2px')
saveCallback()
return
}
// ,
while (articleParseing) {
console.info('%c检测到正在解析, 等待解析完成', 'background:#AD7736;color:#fff;padding-top:2px')
await sleep(100)
}
articleChanged = false
@ -463,7 +535,6 @@ const saveCurArticleContent = async (auto: boolean = false) => {
name: curArticle.value!.name,
markdown: cmw.getDocString(),
html: PreviewRef.value.innerHTML,
toc: JSON.stringify(articleToc.value),
references: articleImg.value.concat(articleLink.value)
}
await articleUpdContentApi(data)
@ -506,21 +577,6 @@ const distoryAutoSaveInterval = () => {
const autoSave = () => {
saveCurArticleContent(true)
}
/**
* 判断当前选中的是否是文章
*/
const curIsArticle = (): boolean => {
if (isNull(curArticle)) {
return false
}
if (isNull(curArticle.value)) {
return false
}
if (isNull(curArticle.value?.type) || curArticle.value?.type != 3) {
return false
}
return true
}
//#endregion
@ -528,42 +584,7 @@ const curIsArticle = (): boolean => {
let cmw: CmWrapper // codemirror editor wrapper
/**
* 文件上传回调
* @param event DragEvent | ClipboardEvent
*/
const uploadFileCallback = async (event: DragEvent | ClipboardEvent) => {
if (!curIsArticle()) return
/**
* 拖拽上传
*/
if (event instanceof DragEvent) {
let data: DataTransfer | null = event.dataTransfer
if (data && data.files.length && data.files.length > 0) {
for (const file of data.files) {
uploadFile(file)
}
}
}
/**
* 黏贴上传
*/
if (event instanceof ClipboardEvent) {
if (!event.clipboardData) return
if (event.clipboardData.items.length === 0) return
for (let i = 0; i < event.clipboardData.items.length; i++) {
const file: File | null = event.clipboardData.items[i].getAsFile()
if (file == null) {
return
}
uploadFile(file)
}
}
}
/**
* 初始化编辑器
* 初始化编辑器, 创建编辑器封装器, 并在编辑器底部增加一个空白页
*/
const initEditor = (_doc?: string) => {
cmw = new CmWrapper(
@ -572,7 +593,7 @@ const initEditor = (_doc?: string) => {
CmWrapper.newState(
() => {
articleParseing = true
debounce(parse, 300)
debounceParse(parse, 300)
},
saveCurArticleContent,
uploadFileCallback
@ -580,15 +601,7 @@ const initEditor = (_doc?: string) => {
EditorRef.value
)
)
//
let editorHeightHolder = document.createElement('div')
editorHeightHolder.style.height = '65vh'
editorHeightHolder.addEventListener('click', () => {
let length = cmw.getDocLength()
cmw.editor.focus()
cmw.insert(length, length, '', length, length)
})
EditorRef.value.appendChild(editorHeightHolder)
appendEditorHolder()
}
/**
* markdown 原文设置到编辑器中, 并且会重置编辑器状态
@ -600,7 +613,8 @@ const setNewState = (md: string): void => {
() => {
articleChanged = true
articleParseing = true
debounce(parse, 300)
allwaysBottom()
debounceParse(parse, 300)
},
saveCurArticleContent,
uploadFileCallback,
@ -610,11 +624,43 @@ const setNewState = (md: string): void => {
parse()
}
/**
* 编辑器底部增加空白占位元素, 点击占位元素会时会聚焦在编辑器
*/
const appendEditorHolder = () => {
//
let editorHeightHolder = document.createElement('div')
editorHeightHolder.style.height = '65vh'
editorHeightHolder.style.position = 'relative'
editorHeightHolder.addEventListener('click', () => {
let length = cmw.getDocLength()
cmw.editor.focus()
cmw.insert(length, length, '', length, length)
})
EditorRef.value.appendChild(editorHeightHolder)
}
/**
* 编辑器滚动条永远置底
*/
const allwaysBottom = async () => {
const clientHeight = EditorRef.value.clientHeight
const scrollTop = EditorRef.value.scrollTop
const scrollHeight = EditorRef.value.scrollHeight
let a = clientHeight + scrollTop
if (a >= scrollHeight - 100) {
scrollWrapper.toBottom()
}
}
//#endregion
//#region ----------------------------------------< marked/preview >-------------------------------
const renderInterval = ref(0) //
const articleHtml = ref('') // html
const renderAsync = ref({
need: 0,
done: 0
})
let immediateParse = false // , ,
/**
* 自定义渲染
@ -630,34 +676,23 @@ const renderer = {
return renderCodespan(src)
},
code(code: string, language: string | undefined, _isEscaped: boolean): string {
return renderCode(code, language, _isEscaped)
return renderCode(code, language, _isEscaped, renderAsync.value)
},
heading(text: any, level: number): string {
articleToc.value.push({ level: level, clazz: 'toc-' + level, index: articleToc.value.length, content: text })
return renderHeading(text, level)
heading(text: string, level: number, raw: string): string {
return renderHeading(text, level, raw)
},
image(href: string | null, _title: string | null, text: string): string {
articleImg.value.push({ targetId: '0', targetName: text, targetUrl: href as string, type: 10 })
return renderImage(href, _title, text)
},
link(href: string | null, title: string | null, text: string): string {
link(href: string, title: string | null | undefined, text: string): string {
let { link, ref } = renderLink(href, title, text, ArticleTreeDocsRef.value.getDocTreeData())
articleLink.value.push(ref)
return link
}
}
/**
* 自定义解析
*/
const tokenizer = {
codespan(src: string): any {
return tokenizerCodespan(src)
}
}
//@ts-ignore
marked.use({ tokenizer: tokenizer, renderer: renderer })
marked.use({ renderer: renderer })
/**
* 解析 markdown html, 并将 html 赋值给 articleHtml
@ -667,35 +702,55 @@ const parse = () => {
immediateParse = false
let mdContent = cmw.getDocString()
clearTocAndImg()
marked.parse(mdContent, { async: true }).then((content: string) => {
articleHtml.value = content
renderInterval.value = Date.now() - begin
articleParseing = false
})
renderAsync.value = {
need: 0,
done: 0
}
marked
.parse(mdContent, { async: true })
.then((content: string) => {
articleHtml.value = content
renderInterval.value = Date.now() - begin
articleParseing = false
})
.then(() => {
nextTick(() => {
parseToc()
}).then(() => {
const clientHeight = EditorRef.value.clientHeight
const scrollTop = EditorRef.value.scrollTop
const scrollHeight = EditorRef.value.scrollHeight
let a = clientHeight + scrollTop
if (a >= scrollHeight - 150) {
setTimeout(() => {
PreviewRef.value.scrollTop = PreviewRef.value.scrollHeight
}, 7)
}
})
})
}
/**
* 防抖, 防止频繁渲染造成的卡顿
*/
let debounceTimeout: NodeJS.Timeout | undefined
function debounce(fn: () => void, time = 500) {
function debounceParse(parseFn: () => void, time = 500) {
if (debounceTimeout != undefined) {
clearTimeout(debounceTimeout)
}
if (immediateParse) {
fn()
parseFn()
} else {
debounceTimeout = setTimeout(fn, time)
debounceTimeout = setTimeout(parseFn, time)
}
}
useResize(EditorRef, PreviewRef, ResizeDividerRef)
//#endregion
//#region ----------------------------------------< TOC >------------------------------------------
const articleToc = ref<any[]>([])
const articleImg = ref<ArticleReference[]>([]) //
const articleLink = ref<ArticleReference[]>([]) //
const articleToc = shallowRef<Toc[]>([])
const articleImg = shallowRef<ArticleReference[]>([]) //
const articleLink = shallowRef<ArticleReference[]>([]) //
const TocRef = ref()
const TocTitleRef = ref()
/**
@ -703,78 +758,42 @@ const TocTitleRef = ref()
* @param level 标题级别
* @param content 标题内容
*/
const toScroll = (level: number, content: string) => {
let id = level + '-' + content
const toScroll = (id: string) => {
let elm: HTMLElement = document.getElementById(id) as HTMLElement
;(elm.parentNode as Element).scrollTop = elm.offsetTop
}
//
const clearTocAndImg = () => {
articleToc.value = []
articleImg.value = []
articleLink.value = []
}
const parseToc = async () => {
parseTocAsync(PreviewRef.value).then((tocs) => (articleToc.value = tocs))
}
useDraggable(TocRef, TocTitleRef)
//#endregion
//#region ----------------------------------------< >----------------------------------------
let scrollWrapper: EPScroll
const initScroll = () => {
const initScroll = async () => {
scrollWrapper = new EPScroll(EditorRef.value, PreviewRef.value, cmw)
}
const scroll = (event: Event | string, source?: string, lineno?: number, colno?: number, error?: Error) => {
scrollWrapper.sycnScroll(event, source, lineno, colno, error)
}
const scrollTopReset = () => {
if (scrollWrapper) {
scrollWrapper.scrollTopReset()
}
const scrollTopReset = () => scrollWrapper.scrollTopReset()
const scrollTopLast = () => scrollWrapper.scrollTopLast()
const addListenerScroll = () => EditorRef.value.addEventListener('scroll', scroll)
const removeListenerScroll = () => EditorRef.value.removeEventListener('scroll', scroll)
const scrollTop = () => scrollWrapper.toTop()
const scrollBottom = () => scrollWrapper.toBottom()
const handleSyncScroll = () => {
editorOperator.value.sycnScroll = scrollWrapper.open()
}
const scrollTopLast = () => {
if (scrollWrapper) {
scrollWrapper.scrollTopLast()
}
}
const addListenerScroll = () => {
EditorRef.value.addEventListener('scroll', scroll)
}
const removeListenerScroll = () => {
EditorRef.value.removeEventListener('scroll', scroll)
}
//#endregion
//#region ----------------------------------------< 2 >----------------------------------------
// let CmEditorRef
// let scrollWrapper: EPScroll
// const initScroll = () => {
// CmEditorRef = document.getElementsByClassName('cm-scroller')[0]
// scrollWrapper = new EPScroll(CmEditorRef, PreviewRef.value, cmw)
// }
// const scroll = (event: Event | string, source?: string, lineno?: number, colno?: number, error?: Error) => {
// scrollWrapper.sycnScroll(event, source, lineno, colno, error)
// }
// const scrollTopLast = () => {
// if (scrollWrapper) {
// scrollWrapper.scrollTopLast()
// }
// }
// const addListenerScroll = () => {
// CmEditorRef.addEventListener('scroll', scroll)
// }
// const removeListenerScroll = () => {
// CmEditorRef.removeEventListener('scroll', scroll)
// }
//#endregion
//#region ----------------------------------------< >----------------------------------------

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,944 @@
<template>
<div class="index-article-root">
<!-- folder menu -->
<div class="doc-container" :style="{ width: docEditorStyle.docs }" v-show="docsExpand">
<div class="doc-tree-menu-container" :style="tempTextareaStyle.docTree">
<ArticleTreeDocs @click-doc="clickCurDoc" ref="ArticleTreeDocsRef"></ArticleTreeDocs>
</div>
<div class="doc-temp-textarea">
<bl-row just="space-between" height="28px" class="doc-temp-textarea-workbench">
<bl-row><img src="@renderer/assets/imgs/note/cd.png" />临时内容(可从便签快速设置)</bl-row>
<div class="iconbl bl-subtract-line" @click="tempTextareaExpand = !tempTextareaExpand"></div>
</bl-row>
<bl-row class="doc-temp-textarea-input" :style="tempTextareaStyle.tempTextarea">
<el-input v-model="tempTextarea" type="textarea" resize="none" @input="tempInput"></el-input>
</bl-row>
</div>
</div>
<!-- editor -->
<div class="editor-container" :style="{ width: docEditorStyle.editor }" v-loading="editorLoading" element-loading-text="正在读取文章内容...">
<div class="editor-tools">
<EditorTools
@save="saveCurArticleContent()"
@preview-full-screen="alt_3()"
@editor-full-screen="alt_4()"
@bold="cmw.commandBold()"
@italic="cmw.commandItalic()"
@strike="cmw.commandStrike()"
@sub="cmw.commandSub()"
@sup="cmw.commandSup()"
@separator="cmw.commandSeparator()"
@blockquote="cmw.commandQuote()"
@blockquote-block="cmw.commandQuoteBlack()"
@blockquote-green="cmw.commandQuoteGreen()"
@blockquote-yellow="cmw.commandQuoteYellow()"
@blockquote-red="cmw.commandQuoteRed()"
@blockquote-blue="cmw.commandQuoteBlue()"
@blockquote-purple="cmw.commandQuotePurple()"
@code="cmw.commandCode()"
@pre="cmw.commandPre()"
@checkbox="cmw.commandCheckBox()"
@unordered="cmw.commandUnordered()"
@ordered="cmw.commandOrdered()"
@table="cmw.commandTable()"
@image="cmw.commandImg()"
@link="cmw.commandLink()">
</EditorTools>
</div>
<!-- 编辑器与预览 -->
<div class="editor-preview" :style="editorStyle">
<div v-if="!curArticle" class="ep-placeholder">
<ArticleIndexPlaceholder></ArticleIndexPlaceholder>
</div>
<div class="operator" ref="EditorOperatorRef">
<el-tooltip popper-class="is-small" effect="light" placement="top" transition="none" :show-after="500" :hide-after="0" :show-arrow="false">
<template #content>
当编辑超大文档时
<bl-row>
可关闭同步预览
<div class="iconbl bl-eye-line" style="transform: rotate(90deg)"></div>
与同步滚动<span class="iconbl bl-scroll"></span>提升性能
</bl-row>
</template>
<div class="iconbl bl-admonish-line"></div>
</el-tooltip>
<el-tooltip
:content="'同步滚动:' + (editorOperator.sycnScroll ? '开启' : '关闭')"
popper-class="is-small"
effect="light"
placement="right"
transition="none"
:show-after="500"
:hide-after="0"
:show-arrow="false">
<div
class="iconbl bl-scroll"
:style="{ color: editorOperator.sycnScroll ? 'var(--el-color-primary-light-3)' : '' }"
@click="handleSyncScroll"></div>
</el-tooltip>
<el-tooltip
content="前往顶部"
popper-class="is-small"
effect="light"
placement="right"
transition="none"
:show-after="500"
:hide-after="0"
:show-arrow="false">
<div class="iconbl bl-a-doubleonline-line" @click="scrollTop"></div>
</el-tooltip>
<el-tooltip
content="前往底部"
popper-class="is-small"
effect="light"
placement="right"
transition="none"
:show-after="500"
:hide-after="0"
:show-arrow="false">
<div class="iconbl bl-a-doubleunderline-line" @click="scrollBottom"></div>
</el-tooltip>
</div>
<div class="gutter-holder" ref="GutterHolderRef"></div>
<div class="editor-codemirror" ref="EditorRef" @click.right="handleEditorClickRight"></div>
<div class="resize-divider" ref="ResizeDividerRef"></div>
<div class="preview-marked bl-preview" ref="PreviewRef" v-html="articleHtml"></div>
</div>
<!-- status -->
<div class="editor-status">
<EditorStatus :render-interval="renderInterval"></EditorStatus>
</div>
<!-- toc -->
<div :class="['bl-preview-toc-absolute', tocsExpand ? 'is-expand-open' : 'is-expand-close']" ref="TocRef">
<div class="toc-title" ref="TocTitleRef">
目录
<span v-show="tocsExpand" style="font-size: 10px">({{ keymaps.hideToc }} 可隐藏)</span>
</div>
<div class="toc-content" v-show="tocsExpand">
<div v-for="toc in articleToc" :key="toc.id" :class="[toc.clazz]" @click="toScroll(toc.id)" v-html="toc.content"></div>
</div>
<div class="img-title">
引用图片
<el-tooltip effect="light" placement="right" :hide-after="0">
<template #content> 重复上传图片后<br />如果图片无变化可刷新缓存 </template>
<span class="iconbl bl-refresh-line" @click="refreshCache"></span>
</el-tooltip>
</div>
<div class="img-content">
<div class="img-wrapper" v-for="image in articleImg" :key="image.targetUrl" @click="showPicInfo(image.targetUrl)">
<img :src="picCacheWrapper(image.targetUrl)" />
</div>
</div>
</div>
</div>
<PictureViewerInfo ref="PictureViewerInfoRef" @saved="refreshCache"></PictureViewerInfo>
<Teleport to="body">
<div
v-show="editorRightMenu.show"
class="editor-right-menu"
:style="{ left: editorRightMenu.clientX + 'px', top: editorRightMenu.clientY + 'px' }">
<div class="menu-content">
<div v-if="isElectron()" class="menu-item" @click="rightMenuCopy"><span class="iconbl bl-copy-line"></span>复制</div>
<div v-if="isElectron()" class="menu-item" @click="rightMenuPaste"><span class="iconbl bl-a-texteditorpastetext-line"></span>黏贴</div>
<div class="menu-item">
<el-upload
name="file"
:action="serverStore.serverUrl + uploadFileApiUrl"
:data="(f: UploadRawFile) => uploadDate(f, curArticle!.pid)"
:headers="{ Authorization: 'Bearer ' + userStore.auth.token }"
:show-file-list="false"
:before-upload="beforeUpload"
:on-success="onUploadSeccess"
:on-error="onError">
<bl-row><span class="iconbl bl-image--line"></span>插入图片</bl-row>
</el-upload>
</div>
<div class="menu-item" @click="upper"><span class="iconbl bl-daxie"></span>大写</div>
<div class="menu-item" @click="lower"><span class="iconbl bl-xiaoxie"></span>小写</div>
<div class="menu-item" @click="formatTable"><span class="iconbl bl-transcript-line"></span>格式化选中表格</div>
<div class="menu-item" @click="openExtenal('https://katex.org/#demo')">
<span class="iconbl bl-a-texteditorsuperscript-line"></span>Katex 在线校验
</div>
<div class="menu-item" @click="openExtenal('https://mermaid.live/edit')">
<span class="iconbl bl-a-statisticalviewpiechart3-line"></span>Mermaid 在线校验
</div>
<div class="menu-item" @click="openExtenal('https://www.emojiall.com/zh-hans')">
<span style="margin-right: 4px; padding: 2px 0">😉</span>Emoji网站
</div>
</div>
</div>
</Teleport>
<Teleport to="body">
<div v-if="articleReferenceView.show" ref="ArticleViewRef" class="article-view-absolute bl-preview" :style="articleReferenceView.style">
<div class="content-view bl-preview" v-html="articleReferenceView.html" :style="editorStyle"></div>
<bl-row class="workbench" just="space-between">
<div class="btns">
<div @click="openArticleWindow(articleReferenceView.articleId)">新窗口打开</div>
</div>
<div class="infos">{{ articleReferenceView.name }}</div>
</bl-row>
</div>
</Teleport>
</div>
</template>
<script setup lang="ts">
// vue
import { ref, computed, provide, onMounted, onBeforeUnmount, onActivated, onDeactivated, defineAsyncComponent, watch, nextTick } from 'vue'
import { ElMessage } from 'element-plus'
import type { UploadProps, UploadRawFile } from 'element-plus'
import { useUserStore } from '@renderer/stores/user'
import { useServerStore } from '@renderer/stores/server'
import { useConfigStore } from '@renderer/stores/config'
import { articleInfoApi, articleUpdContentApi, uploadFileApiUrl } from '@renderer/api/blossom'
// utils
import { Local } from '@renderer/assets/utils/storage'
import { isBlank, isNull } from '@renderer/assets/utils/obj'
import { sleep, isElectron } from '@renderer/assets/utils/util'
import { openExtenal, writeText, readText, openNewArticleWindow } from '@renderer/assets/utils/electron'
import { formartMarkdownTable } from '@renderer/assets/utils/format-table'
// component
import ArticleTreeDocs from './ArticleTreeDocs.vue'
import ArticleIndexPlaceholder from './ArticleIndexPlaceholder.vue'
import EditorTools from './EditorTools.vue'
// ts
import hotkeys from 'hotkeys-js'
import Notify from '@renderer/scripts/notify'
import { useDraggable } from '@renderer/scripts/draggable'
import type { shortcutFunc } from '@renderer/scripts/shortcut-register'
import { treeToInfo, provideKeyDocInfo, provideKeyCurArticleInfo, isArticle } from '@renderer/views/doc/doc'
import { TempTextareaKey, ArticleReference, DocEditorStyle, parseTocAsync } from './scripts/article'
import type { Toc } from './scripts/article'
import { beforeUpload, onError, picCacheWrapper, picCacheRefresh, uploadForm, uploadDate } from '@renderer/views/picture/scripts/picture'
import { useResize } from './scripts/editor-preview-resize'
// codemirror
import { CmWrapper } from './scripts/codemirror'
// marked
import marked, { renderBlockquote, renderCode, renderCodespan, renderHeading, renderImage, renderTable, renderLink } from './scripts/markedjs'
import { EPScroll } from './scripts/editor-preview-scroll'
import { useArticleHtmlEvent } from './scripts/article-html-event'
import { shallowRef } from 'vue'
import { keymaps } from './scripts/editor-tools'
//#region -- mounted
const PictureViewerInfo = defineAsyncComponent(() => import('@renderer/views/picture/PictureViewerInfo.vue'))
// const EditorTools = defineAsyncComponent(() => import('./EditorTools.vue'))
const EditorStatus = defineAsyncComponent(() => import('./EditorStatus.vue'))
let isMounted = false
onMounted(() => {
initEditor()
initScroll()
addListenerScroll()
initAutoSaveInterval()
if (!isMounted) {
enterView()
bindKeys()
}
})
onBeforeUnmount(() => {
unbindKeys()
removeListenerEditorRightMenu()
removeListenerScroll()
distoryAutoSaveInterval()
})
onActivated(() => {
if (isMounted) {
enterView()
bindKeys()
}
isMounted = true
})
onDeactivated(() => {
exitView()
unbindKeys()
})
const userStore = useUserStore()
const serverStore = useServerStore()
const { editorStyle } = useConfigStore()
watch(
() => userStore.userinfo.id,
(_newId: string, _oldId: string) => {
curDoc.value = undefined
curArticle.value = undefined
setNewState('')
}
)
//#endregion
//#region ----------------------------------------< >--------------------------------------
const GutterHolderRef = ref() // editor gutter holder
const EditorRef = ref() // editor
const ResizeDividerRef = ref() // editor&preview resize dom
const EditorOperatorRef = ref()
const PreviewRef = ref() // html
const editorOperator = ref({
syncParse: true,
sycnScroll: true
})
/**
* 文档列表的展开和收起
*/
const docsExpand = ref<boolean>(true)
const tocsExpand = ref<boolean>(true)
const docEditorStyle = computed<DocEditorStyle>(() => {
if (!docsExpand.value) {
return { docs: '0px', editor: '100%' }
}
return { docs: '250px', editor: 'calc(100% - 250px)' }
})
/**
* 编辑器和预览的展开收起
*/
let previewFullScreen = false //
let editorFullScreen = false //
const changeEditorPreviewStyle = () => {
if (previewFullScreen) {
GutterHolderRef.value.style.width = '0px'
EditorRef.value.style.width = '0px'
PreviewRef.value.style.width = '100%'
PreviewRef.value.style.padding = '10px 20px 0 30px'
EditorOperatorRef.value.style.display = 'none'
return
}
if (editorFullScreen) {
GutterHolderRef.value.style.width = '50px'
EditorRef.value.style.width = 'calc(100% - 6px)'
PreviewRef.value.style.width = '0'
PreviewRef.value.style.padding = '0'
EditorOperatorRef.value.style.display = 'none'
return
}
GutterHolderRef.value.style.width = '50px'
EditorRef.value.style.width = '50%'
PreviewRef.value.style.width = '50%'
PreviewRef.value.style.padding = '10px 20px 0 30px'
EditorOperatorRef.value.style.display = 'block'
EditorOperatorRef.value.style.left = 'calc(50% - 0.5px)'
}
/**
* 临时文本框
*/
const tempTextarea = ref('')
const tempTextareaExpand = ref(true)
const tempTextareaStyle = computed<any>(() => {
if (tempTextareaExpand.value) {
return {
docTree: { height: 'calc(100% - 178px)' },
tempTextarea: { height: '150px', padding: '10px' }
}
}
return {
docTree: { height: 'calc(100% - 28px)' },
tempTextarea: { height: '0', padding: '' }
}
})
const initTempTextarea = () => {
tempTextarea.value = Local.get('editor_temp_textarea_value')
}
const tempInput = (value: string) => {
Local.set(TempTextareaKey, value)
}
/**
* 进入页面时, 保存文章
*/
const enterView = () => {
autoSave()
initTempTextarea()
scrollTopLast()
}
/**
* 退出页面时, 保存文章
*/
const exitView = () => {
autoSave()
}
useResize(EditorRef, PreviewRef, ResizeDividerRef, EditorOperatorRef)
//#endregion
//#region ----------------------------------------< >--------------------------------------
const PictureViewerInfoRef = ref()
const showPicInfo = (url: string) => {
PictureViewerInfoRef.value.showPicInfo(url)
}
const refreshCache = () => {
picCacheRefresh()
parse()
}
/**
* 右键菜单的上传回调
* @param resp
* @param file
*/
const onUploadSeccess: UploadProps['onSuccess'] = (resp, file) => {
if (resp.code === '20000') {
cmw.insertBlockCommand(`\n![${file.name}](${resp.data})\n`)
} else {
Notify.error(resp.msg, '上传失败')
}
}
/**
* 拖拽和黏贴上传
* @param file 文件
*/
const uploadFile = (file: File) => {
uploadForm(file, curArticle.value!.pid, (url: string) => {
cmw.insertBlockCommand(`\n![${file.name}](${url})\n`)
})
}
/**
* 文件上传回调
* @param event DragEvent | ClipboardEvent
*/
const uploadFileCallback = async (event: DragEvent | ClipboardEvent) => {
if (!isArticle(curArticle.value)) return
/**
* 拖拽上传
*/
if (event instanceof DragEvent) {
let data: DataTransfer | null = event.dataTransfer
if (data && data.files.length && data.files.length > 0) {
for (const file of data.files) {
uploadFile(file)
}
}
}
/**
* 黏贴上传
*/
if (event instanceof ClipboardEvent) {
if (!event.clipboardData) return
if (event.clipboardData.items.length === 0) return
for (let i = 0; i < event.clipboardData.items.length; i++) {
const file: File | null = event.clipboardData.items[i].getAsFile()
if (file == null) {
return
}
uploadFile(file)
}
}
}
//#endregion
//#region ----------------------------------------< html >----------------------------
const ArticleViewRef = ref()
const { articleReferenceView } = useArticleHtmlEvent(ArticleViewRef)
const openArticleWindow = (id: string) => {
openNewArticleWindow('article_window_' + id, id)
}
//#endregion
//#region ----------------------------------------< >----------------------------
const editorLoading = ref(false) // eidtor loading
const ArticleTreeDocsRef = ref()
const curDoc = ref<DocInfo>() // , , ,
const curArticle = ref<DocInfo>() // ,
// , 5
const authSaveMs = 5 * 60 * 1000
//
// , true , false
let articleParseing = false
// , , true, , false
let articleChanged = false
//
let lastSaveTime: number = new Date().getTime()
//
let autoSaveInterval: NodeJS.Timeout
//
let editorLoadingTimeout: NodeJS.Timeout
provide(provideKeyDocInfo, curDoc)
provide(provideKeyCurArticleInfo, curArticle)
/**
* 点击 doc title 的回调, 用于选中某个文档
* 选中分为两种
* 1:选中的是文件夹
* 2:选中的是文章, 则查询文章内容,
*
* @param tree
*/
const clickCurDoc = async (tree: DocTree) => {
let doc: DocInfo = treeToInfo(tree)
curDoc.value = doc
// , ,
if (doc.type == 3) {
// ,
if (isArticle(curArticle.value) && curArticle.value!.id == doc.id) {
return
}
editorLoadingTimeout = setTimeout(() => (editorLoading.value = true), 100)
await saveCurArticleContent(true)
clearTocAndImg()
await articleInfoApi({ id: doc.id, showToc: false, showMarkdown: true, showHtml: false })
.then((resp) => {
if (isNull(resp.data)) {
return
}
curArticle.value = resp.data
//
immediateParse = true
if (isBlank(resp.data.markdown)) {
setNewState('')
} else {
setNewState(resp.data.markdown)
}
})
.finally(() => {
if (editorLoadingTimeout) clearTimeout(editorLoadingTimeout)
editorLoading.value = false
articleChanged = false
})
nextTick(() => {
scrollTopReset()
})
}
}
/**
* 保存文章的正文, 并更新编辑器状态栏中的版本, 字数, 修改时间等信息.
*
* @param auto 是否为自动保存, 如果是自动保存, 则不弹出保存成功的提示框, 避免在非用户主动操作下弹框
*/
const saveCurArticleContent = async (auto: boolean = false) => {
if (!isArticle(curArticle.value)) {
return
}
const saveCallback = () => {
if (!auto) {
ElMessage.success({ message: '保存成功', duration: 1000, offset: 70, grouping: true })
}
}
//
if (!articleChanged) {
console.info('%c文档内容无变化, 无需保存', 'background:#AD8CF2;color:#fff;')
saveCallback()
return
}
// ,
while (articleParseing) {
console.log('检测到正在解析, 等待解析完成')
await sleep(100)
}
articleChanged = false
let data = {
id: curArticle.value!.id,
name: curArticle.value!.name,
markdown: cmw.getDocString(),
html: PreviewRef.value.innerHTML,
references: articleImg.value.concat(articleLink.value)
}
await articleUpdContentApi(data)
.then((resp) => {
lastSaveTime = new Date().getTime()
curArticle.value!.words = resp.data.words as number
curArticle.value!.updTime = resp.data.updTime as string
if (curArticle.value!.version != undefined) {
curArticle.value!.version = curArticle.value!.version + 1
} else {
curArticle.value!.version = 1
}
saveCallback()
})
.catch(() => {
articleChanged = true
})
}
/**
* 初始化自动保存定时器
* 如果 authSaveMs 时间没有保存, 则自动保存.
*/
const initAutoSaveInterval = () => {
autoSaveInterval = setInterval(() => {
let current = new Date().getTime()
if (current - lastSaveTime > authSaveMs) {
autoSave()
}
}, 30 * 1000)
}
/**
* 销毁自动保存定时器
*/
const distoryAutoSaveInterval = () => {
clearInterval(autoSaveInterval)
}
/**
* 自动保存, 该种方式不会有保存成功的提示
*/
const autoSave = () => {
saveCurArticleContent(true)
}
//#endregion
//#region ----------------------------------------< codemirror/editor >----------------------------
let cmw: CmWrapper // codemirror editor wrapper
/**
* 初始化编辑器, 创建编辑器封装器, 并在编辑器底部增加一个空白页
*/
const initEditor = (_doc?: string) => {
cmw = new CmWrapper(
CmWrapper.newEditor(
// state
CmWrapper.newState(
() => {
articleParseing = true
debounceParse(parse, 300)
},
saveCurArticleContent,
uploadFileCallback
),
EditorRef.value
)
)
appendEditorHolder()
}
/**
* markdown 原文设置到编辑器中, 并且会重置编辑器状态
* @param md markdown
*/
const setNewState = (md: string): void => {
cmw.setState(
CmWrapper.newState(
() => {
articleChanged = true
articleParseing = true
allwaysBottom()
debounceParse(parse, 300)
},
saveCurArticleContent,
uploadFileCallback,
md
)
)
parse()
}
/**
* 编辑器底部增加空白占位元素, 点击占位元素会时会聚焦在编辑器
*/
const appendEditorHolder = () => {
//
let editorHeightHolder = document.createElement('div')
editorHeightHolder.style.height = '65vh'
editorHeightHolder.style.position = 'relative'
editorHeightHolder.addEventListener('click', () => {
let length = cmw.getDocLength()
cmw.editor.focus()
cmw.insert(length, length, '', length, length)
})
EditorRef.value.appendChild(editorHeightHolder)
}
/**
* 编辑器滚动条永远置底
*/
const allwaysBottom = async () => {
const clientHeight = EditorRef.value.clientHeight
const scrollTop = EditorRef.value.scrollTop
const scrollHeight = EditorRef.value.scrollHeight
let a = clientHeight + scrollTop
if (a >= scrollHeight - 100) {
scrollWrapper.toBottom()
}
}
//#endregion
//#region ----------------------------------------< marked/preview >-------------------------------
const renderInterval = ref(0) //
const articleHtml = ref('') // html
const renderAsync = ref({
need: 0,
done: 0
})
let immediateParse = false // , ,
/**
* 自定义渲染
*/
const renderer = {
table(header: string, body: string): string {
return renderTable(header, body)
},
blockquote(quote: string): string {
return renderBlockquote(quote)
},
codespan(src: string): string {
return renderCodespan(src)
},
code(code: string, language: string | undefined, _isEscaped: boolean): string {
return renderCode(code, language, _isEscaped, renderAsync.value)
},
heading(text: string, level: number, raw: string): string {
return renderHeading(text, level, raw)
},
image(href: string | null, _title: string | null, text: string): string {
articleImg.value.push({ targetId: '0', targetName: text, targetUrl: href as string, type: 10 })
return renderImage(href, _title, text)
},
link(href: string, title: string | null | undefined, text: string): string {
let { link, ref } = renderLink(href, title, text, ArticleTreeDocsRef.value.getDocTreeData())
articleLink.value.push(ref)
return link
}
}
marked.use({ renderer: renderer })
/**
* 解析 markdown html, 并将 html 赋值给 articleHtml
*/
const parse = () => {
const begin = Date.now()
immediateParse = false
let mdContent = cmw.getDocString()
clearTocAndImg()
renderAsync.value = {
need: 0,
done: 0
}
marked
.parse(mdContent, { async: true })
.then((content: string) => {
articleHtml.value = content
renderInterval.value = Date.now() - begin
articleParseing = false
})
.then(() => {
nextTick(() => {
parseToc()
}).then(() => {
const clientHeight = EditorRef.value.clientHeight
const scrollTop = EditorRef.value.scrollTop
const scrollHeight = EditorRef.value.scrollHeight
let a = clientHeight + scrollTop
if (a >= scrollHeight - 150) {
setTimeout(() => {
PreviewRef.value.scrollTop = PreviewRef.value.scrollHeight
}, 7)
}
})
setTimeout(() => {
console.log(renderAsync.value)
}, 500)
})
}
/**
* 防抖, 防止频繁渲染造成的卡顿
*/
let debounceTimeout: NodeJS.Timeout | undefined
function debounceParse(parseFn: () => void, time = 500) {
if (debounceTimeout != undefined) {
clearTimeout(debounceTimeout)
}
if (immediateParse) {
parseFn()
} else {
debounceTimeout = setTimeout(parseFn, time)
}
}
//#endregion
//#region ----------------------------------------< TOC >------------------------------------------
const articleToc = shallowRef<Toc[]>([])
const articleImg = shallowRef<ArticleReference[]>([]) //
const articleLink = shallowRef<ArticleReference[]>([]) //
const TocRef = ref()
const TocTitleRef = ref()
/**
* 跳转至指定ID位置,ID为 标题级别-标题内容
* @param level 标题级别
* @param content 标题内容
*/
const toScroll = (id: string) => {
let elm: HTMLElement = document.getElementById(id) as HTMLElement
;(elm.parentNode as Element).scrollTop = elm.offsetTop
}
//
const clearTocAndImg = () => {
articleImg.value = []
articleLink.value = []
}
const parseToc = async () => {
parseTocAsync(PreviewRef.value).then((tocs) => (articleToc.value = tocs))
}
useDraggable(TocRef, TocTitleRef)
//#endregion
//#region ----------------------------------------< >----------------------------------------
let scrollWrapper: EPScroll
const initScroll = async () => {
scrollWrapper = new EPScroll(EditorRef.value, PreviewRef.value, cmw)
}
const scroll = (event: Event | string, source?: string, lineno?: number, colno?: number, error?: Error) => {
scrollWrapper.sycnScroll(event, source, lineno, colno, error)
}
const scrollTopReset = () => scrollWrapper.scrollTopReset()
const scrollTopLast = () => scrollWrapper.scrollTopLast()
const addListenerScroll = () => EditorRef.value.addEventListener('scroll', scroll)
const removeListenerScroll = () => EditorRef.value.removeEventListener('scroll', scroll)
const scrollTop = () => scrollWrapper.toTop()
const scrollBottom = () => scrollWrapper.toBottom()
const handleSyncScroll = () => {
editorOperator.value.sycnScroll = scrollWrapper.open()
}
//#endregion
//#region ----------------------------------------< >----------------------------------------
const editorRightMenu = ref<RightMenu>({ show: false, clientX: 0, clientY: 0 })
const rightMenuHeight = isElectron() ? 270 : 220
const handleEditorClickRight = (event: MouseEvent) => {
event.preventDefault()
editorRightMenu.value = { show: false, clientX: 0, clientY: 0 }
let y = event.clientY
if (document.body.clientHeight - event.clientY < rightMenuHeight) {
y = event.clientY - rightMenuHeight
}
editorRightMenu.value = { show: true, clientX: event.clientX, clientY: y }
setTimeout(() => {
document.body.addEventListener('click', closeEditorRightMenu)
}, 100)
}
const closeEditorRightMenu = () => {
removeListenerEditorRightMenu()
editorRightMenu.value.show = false
}
const removeListenerEditorRightMenu = () => {
document.body.removeEventListener('click', closeEditorRightMenu)
}
/** 复制当前选中内容 */
const rightMenuCopy = () => {
writeText(cmw.getSelectionRangesText())
}
/** 右键黏贴功能 */
const rightMenuPaste = () => {
cmw.insertBlockCommand(readText())
}
/** 转大写功能 */
const upper = () => {
cmw.toUpper()
}
/** 转小写功能 */
const lower = () => {
cmw.toLower()
}
/**
* 右键格式化表格功能
*/
const formatTable = () => {
let ranges = cmw.getSlelctionRangesArr()
if (ranges.length < 1) {
Notify.error('未选中内容')
return
}
if (ranges.length > 1) {
Notify.error('选中内容过多')
return
}
let text = cmw.sliceDoc(ranges[0].from, ranges[0].to)
if (isBlank(text)) {
return
}
cmw.insertBlockCommand(formartMarkdownTable(text))
}
//#endregion
//#region ----------------------------------------< >-------------------------------------
const alt_1: shortcutFunc = (): void => {
docsExpand.value = !docsExpand.value
}
const alt_2: shortcutFunc = (): void => {
tocsExpand.value = !tocsExpand.value
}
//
const alt_3: shortcutFunc = (): void => {
previewFullScreen = !previewFullScreen
if (previewFullScreen) {
editorFullScreen = false
}
changeEditorPreviewStyle()
}
//
const alt_4: shortcutFunc = (): void => {
editorFullScreen = !editorFullScreen
if (previewFullScreen) {
previewFullScreen = false
}
changeEditorPreviewStyle()
}
hotkeys.filter = function (_event) {
return true
}
const bindKeys = () => {
hotkeys('alt+1, command+1', () => {
alt_1()
return false
})
hotkeys('alt+2, command+2', () => {
alt_2()
return false
})
hotkeys('alt+3, command+3', () => {
alt_3()
return false
})
hotkeys('alt+4, command+4', () => {
alt_4()
return false
})
}
const unbindKeys = () => {
hotkeys.unbind('alt+1, command+1')
hotkeys.unbind('alt+2, command+2')
hotkeys.unbind('alt+3, command+3')
hotkeys.unbind('alt+4, command+4')
}
//#endregion
</script>
<style scoped lang="scss">
@import './styles/article-index.scss';
@import './styles/article-view-absolute.scss';
@import './styles/editor-right-menu.scss';
@import './styles/bl-preview-toc.scss';
@import './styles/article-backtop.scss';
@import '@renderer/assets/styles/bl-loading-spinner.scss';
</style>

View File

@ -57,7 +57,7 @@ import { keymaps } from './scripts/editor-tools'
@include flex(column, center, center);
.iconbl {
@include themeColor(#dbdbdb, #323232);
@include themeColor(#dbdbdb, #444444);
font-size: 25px;
padding: 3px 2px;
transition: 0.3s;
@ -65,11 +65,11 @@ import { keymaps } from './scripts/editor-tools'
}
.desc-line {
@include themeBorder(1px, #dcdcdc, #323232);
@include themeBorder(1px, #dcdcdc, #444444);
position: absolute;
.desc-text {
@include themeColor(#dbdbdb, #323232);
@include themeColor(#dbdbdb, #444444);
font-size: 13px;
position: absolute;
width: 100px;
@ -153,13 +153,13 @@ import { keymaps } from './scripts/editor-tools'
width: 210px;
font-size: 13px;
.label {
@include themeColor(#dbdbdb, #323232);
@include themeColor(#dbdbdb, #444444);
width: 60px;
text-align: right;
margin-right: 10px;
}
.key {
@include themeBg(#f1f1f1, #323232);
@include themeBg(#f1f1f1, #444444);
@include themeColor(#c6c6c6, #151515);
border-radius: 4px;
padding: 2px 5px;

View File

@ -19,7 +19,7 @@
<div class="name">{{ recycle.name }}</div>
<div class="size">{{ recycle.delTime }}|{{ remainingDays(recycle.delTime) }}天后删除</div>
<el-button class="restore-btn" @click="restore(recycle.id)"><span class="iconbl bl-a-cloudupload-line"></span></el-button>
<el-button class="download-btn" @click=""><span class="iconbl bl-folder-download-line"></span></el-button>
<el-button class="download-btn" @click="download(recycle.id)"><span class="iconbl bl-folder-download-line"></span></el-button>
</div>
</div>
</div>
@ -29,7 +29,7 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useUserStore } from '@renderer/stores/user'
import { articleRecycleListApi, articleRecycleRestoreApi } from '@renderer/api/blossom'
import { articleRecycleListApi, articleRecycleRestoreApi, articleRecycleDownloadApi } from '@renderer/api/blossom'
import { isNull } from '@renderer/assets/utils/obj'
import { isEmpty } from 'lodash'
import dayjs from 'dayjs'
@ -68,6 +68,26 @@ const restore = (id: string) => {
getBackupList()
})
}
const download = (id: string) => {
articleRecycleDownloadApi({ id: id }).then((resp) => {
let filename: string = resp.headers.get('content-disposition')
let filenameRegex = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/
let matches = filenameRegex.exec(filename)
if (matches != null && matches[1]) {
filename = decodeURI(matches[1].replace(/['"]/g, ''))
}
filename = decodeURI(filename)
let a = document.createElement('a')
let blob = new Blob([resp.data], { type: 'text/plain' })
let objectUrl = URL.createObjectURL(blob)
a.setAttribute('href', objectUrl)
a.setAttribute('download', filename)
a.click()
URL.revokeObjectURL(a.href)
a.remove()
})
}
</script>
<style scoped lang="scss">

View File

@ -68,8 +68,9 @@ let stat = ref({
outside: 0
})
let inside = { itemStyle: {}, label: {} }
let outside = { itemStyle: {}, label: {} }
let inside: any = { itemStyle: {}, label: {} }
let insideUnknown: any = { itemStyle: {}, label: {} }
let outside: any = { itemStyle: {}, label: {} }
const changeStyle = () => {
let primaryColor = getPrimaryColor()
//
@ -85,6 +86,17 @@ const changeStyle = () => {
textBorderWidth: 2
}
}
insideUnknown = {
itemStyle: {
color: isDark.value ? '#7B0000' : '#EB6969'
},
label: {
fontSize: 13,
color: isDark.value ? '#BABABA' : '#030303',
textBorderColor: isDark.value ? '#7B0000' : '#EB6969',
textBorderWidth: 1
}
}
outside = {
itemStyle: {
color: isDark.value ? '#7B5E00' : '#FDC81A87'
@ -116,6 +128,9 @@ const getArticleRefList = (onlyInner: boolean) => {
node.itemStyle = inside.itemStyle
node.label = inside.label
stat.value.inside += 1
} else if (node.artType === 12) {
node.itemStyle = insideUnknown.itemStyle
node.label = insideUnknown.label
} else if (node.artType == 21) {
node.itemStyle = outside.itemStyle
node.label = outside.label
@ -139,6 +154,9 @@ const ascending = 1
const getLinkCount = (name: string, links: any[]): number => {
let count: number = 20
for (let i = 0; i < links.length; i++) {
if (count >= 100) {
break
}
let link = links[i]
if (link.source == name) {
count += ascending
@ -172,10 +190,19 @@ const renderChart = () => {
userStore.userinfo.userParams.WEB_ARTICLE_URL + params.data.artId
}</a></div>`
}
return `<div class="chart-graph-article-ref-tooltip">
console.log(params.data)
let type = ''
if (params.data.artType === 11) {
type = `<div>类型: 内部文章</div>`
} else if (params.data.artType === 12) {
type = `<div style="color:${insideUnknown.itemStyle.color}">类型: 未知文章, 可能是文章ID错误或已被删除</div>`
} else if (params.data.artType === 21) {
type = `<div>类型: 外网文章</div>`
}
return `<div class="chart-graph-article-ref-tooltip" style="border:1px solid ${params.data.itemStyle.color}">
<div class="title">${params.data.name}</div>
<div class="content">
<div>类型: ${params.data.inner ? '内部文章' : '外网文章'}</div>
${type}
${url}
</div>
</div>`
@ -250,7 +277,7 @@ const renderChart = () => {
// edgeLabel: { show: false },
},
blur: {
itemStyle: { opacity: 0.1 },
// itemStyle: { opacity: 0.1 },
lineStyle: { opacity: 0.1 },
label: { show: false },
edgeLabel: { show: false }
@ -377,10 +404,10 @@ onUnmounted(() => {
white-space: normal;
background-color: var(--bl-html-color);
border-radius: 4px;
border: 1px solid var(--el-color-primary-light-5);
color: var(--bl-text-color);
.title {
@include font(15px, 300);
@include font(15px, 500);
border-bottom: 1px solid var(--el-color-primary-light-5);
padding: 10px;
overflow: hidden;

View File

@ -453,6 +453,7 @@ const unbindKeys = () => {
padding: 0 3px 2px 3px;
border-top: 1px solid var(--el-border-color);
overflow-y: scroll;
color: var(--bl-text-color-light);
}
.workbench {
@ -487,6 +488,10 @@ const unbindKeys = () => {
border-left: 1px solid var(--el-color-primary);
display: block;
}
.markdown {
color: var(--bl-text-color);
}
}
&::-webkit-scrollbar {

View File

@ -118,7 +118,7 @@
<div v-if="curDoc.ty !== 3" @click="handleShowArticleImportDialog()"><span class="iconbl bl-file-upload-line"></span>导入文章</div>
<!-- 更多二级菜单 -->
<div @mouseenter="handleHoverRightMenuLevel2($event, 2)">
<div @mouseenter="handleHoverRightMenuLevel2($event, 2)" data-bl-prevet="true">
<span class="iconbl bl-a-rightsmallline-line"></span>
<span class="iconbl bl-apps-line"></span>更多
<div class="menu-content-level2" :style="rMenuLevel2">
@ -147,7 +147,7 @@
<div v-if="curDoc.ty === 3" @click="createUrl('tempVisit', true)"><span class="iconbl bl-visit"></span>浏览器临时访问</div>
<!-- 导出及二级菜单 -->
<div v-if="curDoc.ty === 3" @mouseenter="handleHoverRightMenuLevel2($event, 4)">
<div v-if="curDoc.ty === 3" @mouseenter="handleHoverRightMenuLevel2($event, 4)" data-bl-prevet="true">
<span class="iconbl bl-a-rightsmallline-line"></span>
<span class="iconbl bl-file-download-line"></span>导出文章
<div class="menu-content-level2" :style="rMenuLevel2">
@ -157,7 +157,7 @@
<div @click="articleBackup('HTML')"><span class="iconbl bl-HTML"></span>导出为本地 HTML</div>
</div>
</div>
<div v-if="curDoc.ty === 3" @mouseenter="handleHoverRightMenuLevel2($event, 2)">
<div v-if="curDoc.ty === 3" @mouseenter="handleHoverRightMenuLevel2($event, 2)" data-bl-prevet="true">
<span class="iconbl bl-a-rightsmallline-line"></span>
<span class="iconbl bl-a-linkspread-line"></span>复制链接
<div class="menu-content-level2" :style="rMenuLevel2">
@ -310,6 +310,9 @@ const getRouteQueryParams = () => {
docTreeActiveArticleId.value = articleId as string
let treeParam: any = { ty: 3, i: articleId }
clickCurDoc(treeParam)
nextTick(() => {
docTreeActiveArticleId.value = articleId as string
})
}
}
/**
@ -422,7 +425,15 @@ const handleClickRightMenu = (doc: DocTree, event: MouseEvent) => {
})
}
const closeTreeDocsMenuShow = () => {
const closeTreeDocsMenuShow = (event: MouseEvent) => {
if (event.target) {
let isPrevent = (event.target as HTMLElement).getAttribute('data-bl-prevet')
if (isPrevent === 'true') {
event.preventDefault()
return
}
}
document.body.removeEventListener('click', closeTreeDocsMenuShow)
rMenu.value.show = false
}
@ -573,11 +584,14 @@ const syncDoc = () => {
const delDoc = () => {
let type = curDoc.value.ty === 3 ? '文章' : '文件夹'
ElMessageBox.confirm(
`是否确定删除${type}: <span style="color:#C02B2B;text-decoration: underline;">${curDoc.value.n}</span>?删除后的文章可在回收站中查看。`,
`<strong>注意:</strong><br/>
1. 公开访问记录将永久删除<br/>
2. 双链引用将永久删除还原后续重新编辑才可再次生成<br/>
是否继续删除${type}: <span style="color:#C02B2B;text-decoration: underline;">${curDoc.value.n}</span>`,
{
confirmButtonText: '确定删除',
cancelButtonText: '我再想想',
type: 'info',
// type: 'warning',
draggable: true,
dangerouslyUseHTMLString: true
}
@ -790,7 +804,6 @@ const openArticle = (article: DocTree) => {
})
}
//#endregion
const clickCurDoc = (tree: DocTree) => {
emits('clickDoc', tree)
}

View File

@ -1,8 +1,8 @@
<template>
<div class="doc-workbench-root">
<bl-col class="workbench-name" just="flex-start" align="flex-end" height="46px" v-show="curDoc !== undefined">
<span>{{ curDoc?.name }}</span>
<span style="font-size: 9px; padding-right: 5px">{{ curDoc?.id }}</span>
<bl-col class="workbench-name" just="flex-start" align="flex-end" height="46px" v-show="curArticle !== undefined">
<span>{{ curArticle?.name }}</span>
<span style="font-size: 9px; padding-right: 5px">{{ curArticle?.id }}</span>
</bl-col>
<bl-row class="wb-page-container">
<Transition name="wbpage-one">
@ -126,7 +126,7 @@
<script setup lang="ts">
import { ref, nextTick, inject, onDeactivated } from 'vue'
import { ArrowUp, ArrowDown } from '@element-plus/icons-vue'
import { provideKeyDocInfo, SortLevelColor } from '@renderer/views/doc/doc'
import { provideKeyCurArticleInfo, SortLevelColor } from '@renderer/views/doc/doc'
import { openNewArticleReferenceWindow } from '@renderer/assets/utils/electron'
import { useConfigStore } from '@renderer/stores/config'
import ArticleInfo from './ArticleInfo.vue'
@ -174,7 +174,7 @@ const toWorkbenchPage = (page: number) => {
//#endregion
//#region --------------------------------------------------< >--------------------------------------------------
const curDoc = inject(provideKeyDocInfo)
const curArticle = inject(provideKeyCurArticleInfo)
const onlyOpen = ref<boolean>(false) //
const onlySubject = ref<boolean>(false) //

View File

@ -4,60 +4,62 @@
<div class="bl-preview-toc-block">
<div class="toc-subtitle">{{ article?.name }}</div>
<div class="toc-subtitle">
<span class="iconbl bl-pen-line"></span> {{ article?.words }} |
<span class="iconbl bl-read-line"></span> {{ article?.uv }} |
<span class="iconbl bl-pen-line"></span> {{ article?.words }} | <span class="iconbl bl-read-line"></span> {{ article?.uv }} |
<span class="iconbl bl-like-line"></span> {{ article?.likes }}
</div>
<div class="toc-subtitle">
<span class="iconbl bl-a-clock3-line"></span> 公开 {{ article?.openTime }}
</div>
<div class="toc-subtitle">
<span class="iconbl bl-a-clock3-line"></span> 修改 {{ article?.updTime }}
</div>
<div class="toc-subtitle"><span class="iconbl bl-a-clock3-line"></span> 公开 {{ article?.openTime }}</div>
<div class="toc-subtitle"><span class="iconbl bl-a-clock3-line"></span> 修改 {{ article?.updTime }}</div>
<div class="toc-title">目录</div>
<div class="toc-content">
<div v-for="toc in tocList" :key="toc.index" :class="[toc.clazz]" @click="toScroll(toc.level, toc.content)">
<div v-for="toc in tocs" :key="toc.id" :class="[toc.clazz]" @click="toScroll(toc.id)">
{{ toc.content }}
</div>
</div>
</div>
<div class="preview bl-preview" :style="editorStyle" v-html="article?.html"></div>
<div class="preview bl-preview" :style="editorStyle" v-html="article?.html" ref="WindowPreviewRef"></div>
<el-backtop target=".preview" :right="50" :bottom="50">
<div class="iconbl bl-send-line backtop"></div>
<div class="iconbl bl-a-doubleonline-line backtop"></div>
</el-backtop>
</div>
</template>
<script setup lang="ts">
import { storeToRefs } from "pinia"
import { ref, onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { ref, onMounted, nextTick } from 'vue'
import { useRoute } from 'vue-router'
import { articleInfoApi } from '@renderer/api/blossom'
import { useConfigStore } from '@renderer/stores/config'
import { parseTocAsync } from './scripts/article'
import type { Toc } from './scripts/article'
const configStore = useConfigStore()
const { editorStyle } = storeToRefs(configStore)
const route = useRoute();
const route = useRoute()
const article = ref<DocInfo>()
const tocList = ref<any>([])
const tocs = ref<Toc[]>([])
const WindowPreviewRef = ref()
/**
* 跳转至指定ID位置,ID为 标题级别-标题内容
* @param level 标题级别
* @param content 标题内容
*/
const toScroll = (level: number, content: string) => {
let id = level + '-' + content
const toScroll = (id: string) => {
let elm: HTMLElement = document.getElementById(id) as HTMLElement
(elm.parentNode as Element).scrollTop = elm.offsetTop
;(elm.parentNode as Element).scrollTop = elm.offsetTop - 40
}
const initPreview = (articleId: string) => {
articleInfoApi({ id: articleId, showToc: true, showMarkdown: false, showHtml: true }).then(resp => {
articleInfoApi({ id: articleId, showToc: false, showMarkdown: false, showHtml: true }).then((resp) => {
article.value = resp.data
tocList.value = JSON.parse(resp.data.toc)
nextTick(() => initToc())
})
}
const initToc = () => {
parseTocAsync(WindowPreviewRef.value).then((toc) => {
tocs.value = toc
})
}
@ -65,7 +67,7 @@ onMounted(() => {
initPreview(route.query.articleId as string)
})
</script>
<style scoped lang=scss>
<style scoped lang="scss">
@import './styles/bl-preview-toc.scss';
@import './styles/article-backtop.scss';
@ -78,6 +80,7 @@ onMounted(() => {
font-size: 15px;
padding: 30px;
overflow-y: overlay;
overflow-x: hidden;
line-height: 23px;
:deep(*) {
@ -88,10 +91,7 @@ onMounted(() => {
:deep(.katex > *) {
font-size: 1.2em !important;
font-family: 'KaTeX_Size1', sans-serif !important;
// font-size: 1.3em !important;
// font-family: 'KaTeX_Math', sans-serif !important;
}
}
}
</style>
</style>

View File

@ -3,51 +3,240 @@
<div class="tomato-bell" ref="TomatoBellRef"></div>
<div class="tools-container">
<!-- 文章的操作 -->
<div class="iconbl bl-a-texteditorsave-line" @click="emits('save')"></div>
<div class="iconbl bl-eye-line" @click="emits('previewFullScreen')"></div>
<div class="iconbl bl-expansion-line" @click="emits('editorFullScreen')"></div>
<el-tooltip
content="保存"
popper-class="is-small"
effect="light"
placement="top"
transition="none"
:hide-after="0"
:show-arrow="false"
:offset="10">
<div class="iconbl bl-a-texteditorsave-line" @click="emits('save')"></div>
</el-tooltip>
<el-tooltip
content="开启/关闭全屏预览"
popper-class="is-small"
effect="light"
placement="top"
transition="none"
:hide-after="0"
:show-arrow="false"
:offset="10">
<div class="iconbl bl-eye-line" @click="emits('previewFullScreen')"></div>
</el-tooltip>
<el-tooltip
content="开启/关闭全屏编辑"
popper-class="is-small"
effect="light"
placement="top"
transition="none"
:hide-after="0"
:show-arrow="false"
:offset="10">
<div class="iconbl bl-expansion-line" @click="emits('editorFullScreen')"></div>
</el-tooltip>
<!-- -->
<div class="divider"></div>
<div class="iconbl bl-bold" @click="emits('bold')"></div>
<div class="iconbl bl-italic" @click="emits('italic')"></div>
<div class="iconbl bl-strikethrough" @click="emits('strike')"></div>
<div class="iconbl bl-a-texteditorsuperscript-line" @click="emits('sup')"></div>
<div class="iconbl bl-a-texteditorsubscript-line" @click="emits('sub')"></div>
<div class="iconbl bl-separator" @click="emits('separator')"></div>
<el-tooltip
content="加粗"
popper-class="is-small"
effect="light"
placement="top"
transition="none"
:hide-after="0"
:show-arrow="false"
:offset="10">
<div class="iconbl bl-bold" @click="emits('bold')"></div>
</el-tooltip>
<el-tooltip
content="斜体"
popper-class="is-small"
effect="light"
placement="top"
transition="none"
:hide-after="0"
:show-arrow="false"
:offset="10">
<div class="iconbl bl-italic" @click="emits('italic')"></div>
</el-tooltip>
<el-tooltip
content="删除线"
popper-class="is-small"
effect="light"
placement="top"
transition="none"
:hide-after="0"
:show-arrow="false"
:offset="10">
<div class="iconbl bl-strikethrough" @click="emits('strike')"></div>
</el-tooltip>
<el-tooltip
content="上标"
popper-class="is-small"
effect="light"
placement="top"
transition="none"
:hide-after="0"
:show-arrow="false"
:offset="10">
<div class="iconbl bl-a-texteditorsuperscript-line" @click="emits('sup')"></div>
</el-tooltip>
<el-tooltip
content="下标"
popper-class="is-small"
effect="light"
placement="top"
transition="none"
:hide-after="0"
:show-arrow="false"
:offset="10">
<div class="iconbl bl-a-texteditorsubscript-line" @click="emits('sub')"></div>
</el-tooltip>
<el-tooltip
content="删除线"
popper-class="is-small"
effect="light"
placement="top"
transition="none"
:hide-after="0"
:show-arrow="false"
:offset="10">
<div class="iconbl bl-separator" @click="emits('separator')"></div>
</el-tooltip>
<!-- -->
<div class="divider"></div>
<el-dropdown>
<div class="iconbl bl-a-rightsmallline-line"></div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="emits('blockquote')"> Normal</el-dropdown-item>
<el-dropdown-item @click="emits('blockquoteBlock')"> Black</el-dropdown-item>
<el-dropdown-item @click="emits('blockquoteGreen')">🟢 Green</el-dropdown-item>
<el-dropdown-item @click="emits('blockquoteYellow')">🟡 Yellow</el-dropdown-item>
<el-dropdown-item @click="emits('blockquoteRed')">🔴 Red</el-dropdown-item>
<el-dropdown-item @click="emits('blockquoteBlue')">🔵 Blue</el-dropdown-item>
<el-dropdown-item @click="emits('blockquotePurple')">🟣 Purple</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<!-- </div> -->
<div class="iconbl bl-single-quotes-r" @click="emits('code')"></div>
<div class="iconbl bl-double-quotes-r" @click="emits('pre')"></div>
<div class="iconbl bl-a-underbox-line" @click="emits('checkbox')"></div>
<div class="iconbl bl-list-unordered" @click="emits('unordered')"></div>
<div class="iconbl bl-list-ordered" @click="emits('ordered')"></div>
<el-tooltip
content="引用"
popper-class="is-small"
effect="light"
placement="top"
transition="none"
:hide-after="0"
:show-arrow="false"
:offset="10">
<el-dropdown>
<div class="iconbl bl-a-rightsmallline-line"></div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="emits('blockquote')"> Normal</el-dropdown-item>
<el-dropdown-item @click="emits('blockquoteBlock')"> Black</el-dropdown-item>
<el-dropdown-item @click="emits('blockquoteGreen')">🟢 Green</el-dropdown-item>
<el-dropdown-item @click="emits('blockquoteYellow')">🟡 Yellow</el-dropdown-item>
<el-dropdown-item @click="emits('blockquoteRed')">🔴 Red</el-dropdown-item>
<el-dropdown-item @click="emits('blockquoteBlue')">🔵 Blue</el-dropdown-item>
<el-dropdown-item @click="emits('blockquotePurple')">🟣 Purple</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</el-tooltip>
<el-tooltip
content="行内代码块"
popper-class="is-small"
effect="light"
placement="top"
transition="none"
:hide-after="0"
:show-arrow="false"
:offset="10">
<div class="iconbl bl-single-quotes-r" @click="emits('code')"></div>
</el-tooltip>
<el-tooltip
content="多行代码块"
popper-class="is-small"
effect="light"
placement="top"
transition="none"
:hide-after="0"
:show-arrow="false"
:offset="10">
<div class="iconbl bl-double-quotes-r" @click="emits('pre')"></div>
</el-tooltip>
<el-tooltip
content="单选框"
popper-class="is-small"
effect="light"
placement="top"
transition="none"
:hide-after="0"
:show-arrow="false"
:offset="10">
<div class="iconbl bl-a-underbox-line" @click="emits('checkbox')"></div>
</el-tooltip>
<el-tooltip
content="无序列表"
popper-class="is-small"
effect="light"
placement="top"
transition="none"
:hide-after="0"
:show-arrow="false"
:offset="10">
<div class="iconbl bl-list-unordered" @click="emits('unordered')"></div>
</el-tooltip>
<el-tooltip
content="有序列表"
popper-class="is-small"
effect="light"
placement="top"
transition="none"
:hide-after="0"
:show-arrow="false"
:offset="10">
<div class="iconbl bl-list-ordered" @click="emits('ordered')"></div>
</el-tooltip>
<!-- 表格及其他 -->
<div class="divider"></div>
<div class="iconbl bl-table-" @click="emits('table')"></div>
<div class="iconbl bl-image--line" @click="emits('image')"></div>
<div class="iconbl bl-link-m" @click="emits('link')"></div>
<el-tooltip
content="表格"
popper-class="is-small"
effect="light"
placement="top"
transition="none"
:hide-after="0"
:show-arrow="false"
:offset="10">
<div class="iconbl bl-table-" @click="emits('table')"></div>
</el-tooltip>
<el-tooltip
content="图片链接"
popper-class="is-small"
effect="light"
placement="top"
transition="none"
:hide-after="0"
:show-arrow="false"
:offset="10">
<div class="iconbl bl-image--line" @click="emits('image')"></div>
</el-tooltip>
<el-tooltip
content="链接"
popper-class="is-small"
effect="light"
placement="top"
transition="none"
:hide-after="0"
:show-arrow="false"
:offset="10">
<div class="iconbl bl-link-m" @click="emits('link')"></div>
</el-tooltip>
<!-- 其他工具 -->
<div class="divider"></div>
<div class="iconbl bl-jianpan-xianxing" @click="handleShowHotKeyDialog"></div>
<el-tooltip
content="快捷键说明"
popper-class="is-small"
effect="light"
placement="top"
transition="none"
:hide-after="0"
:show-arrow="false"
:offset="10">
<div class="iconbl bl-jianpan-xianxing" @click="handleShowHotKeyDialog"></div>
</el-tooltip>
<!-- 番茄 -->
<el-popover placement="bottom" :width="220" trigger="click" popper-style="padding:0;">

View File

@ -1,3 +1,5 @@
import { isNull } from '@renderer/assets/utils/obj'
/**
* localStorage key
*/
@ -41,7 +43,57 @@ export interface ArticleReference {
*
* 10 : picture
* 11 : inner article
* 12 : unknown inner article
* 21 : public article
*/
type: 10 | 11 | 21
type: 10 | 11 | 12 | 21
}
/**
*
*/
export interface Toc {
content: string
clazz: string
id: string
}
/**
* ,
*
* @param ele
* @returns
*/
export const parseTocAsync = async (ele: HTMLElement): Promise<Toc[]> => {
let heads = ele.querySelectorAll('h1, h2, h3, h4, h5, h6')
let tocs: Toc[] = []
for (let i = 0; i < heads.length; i++) {
let head: Element = heads[i]
let level = 1
let content = (head as HTMLElement).innerText
let id = head.id
switch (head.localName) {
case 'h1':
level = 1
break
case 'h2':
level = 2
break
case 'h3':
level = 3
break
case 'h4':
level = 4
break
case 'h5':
level = 5
break
case 'h6':
level = 6
break
}
let toc: Toc = { content: content, clazz: 'toc-' + level, id: id }
tocs.push(toc)
}
return tocs
}

View File

@ -53,7 +53,10 @@ export const cwTheme: any = {
backgroundColor: 'var(--bl-editor-gutters-bg-color)',
borderColor: 'var(--bl-editor-gutters-border-color)',
color: 'var(--bl-editor-gutters-color)',
fontSize: '12px'
fontSize: '12px',
width: '50px',
minWidth: '50px',
maxWidth: '50px'
},
'.cm-activeLineGutter': {
backgroundColor: 'var(--bl-editor-gutters-bg-color)',
@ -364,7 +367,7 @@ export class CmWrapper {
return editor.state.doc.toString()
}
/**
*
*
* @param editor
* @returns
*/
@ -442,6 +445,9 @@ export class CmWrapper {
getDocLength = (): number => {
return CmWrapper.getDocLength(this._editor)
}
getTotalLine = () => {
return this.editor.state.doc.lines
}
getSelectionRangesText = (): string => {
return CmWrapper.getSelectionRangesText(this._editor)
}

View File

@ -10,9 +10,9 @@ import type { Ref } from 'vue'
export const useResize = (
editorRef: Ref<HTMLElement | undefined>,
previewRef: Ref<HTMLElement | undefined>,
resizeDividerRef: Ref<HTMLElement | undefined>
resizeDividerRef: Ref<HTMLElement | undefined>,
operatorRef: Ref<HTMLElement | undefined>
) => {
const onMousedown = (_e: MouseEvent) => {
const targetRect = editorRef.value!.getBoundingClientRect()
// editor 距离应用左侧的距离
@ -24,6 +24,7 @@ export const useResize = (
const onMousemove = (e: MouseEvent) => {
const x = Math.max(0, e.clientX - targetLeft)
editorRef.value!.style.width = `${x}px`
operatorRef.value!.style.left = `${x + 1}px`
previewRef.value!.style.width = `calc(100% - ${x}px - 3px)`
}
@ -59,4 +60,4 @@ export const useResize = (
onBeforeUnmount(() => {
offResize()
})
}
}

View File

@ -27,6 +27,10 @@ export class EPScroll {
*
*/
private _scrollTop: number = 0
/**
*
*/
private _open: boolean = true
constructor(editor: HTMLElement, previre: HTMLElement, cmw: CmWrapper | undefined) {
this._editor = editor
@ -35,26 +39,63 @@ export class EPScroll {
this._scrollTop = 0
}
/**
*
*/
public get scrollTop() {
return this._scrollTop
}
/**
*
*/
public scrollTopReset() {
this._scrollTop = 0
this._editor.scrollTo({ top: 0 })
this.toTop()
}
/**
*
*/
public scrollTopLast() {
this._editor.scrollTo({ top: this._scrollTop })
}
public sycnScroll(_event: Event | string, _source?: string, _lineno?: number, _colno?: number, _error?: Error): any {
/**
*
*/
public toTop() {
this._editor.scrollTo({ top: 0 })
}
/**
*
*/
public toBottom() {
this._editor.scrollTo({ top: 999999999 })
}
/**
* /
* @returns
*/
public open(): boolean {
this._open = !this._open
return this._open
}
public sycnScroll(_event: Event | string, _source?: string, _lineno?: number, _colno?: number, _error?: Error): void {
if (this._editor == undefined) {
return
}
// console.log(this._editor?.scrollHeight, this._editor?.clientHeight, this._editor?.scrollTop)
this._scrollTop = this._editor.scrollTop
// 如果不开启同步滚动, 则返回
if (!this._open) {
return
}
// 如果在头部附近
if (this._editor.scrollTop < 5) {
this._preview.scrollTo({ top: 0 })
@ -84,7 +125,7 @@ export class EPScroll {
// 将不可见的的 html 转换为 dom 对象, 是一个从 <html> 标签开始的 dom 对象
const invisibleDomAll = new DOMParser().parseFromString(invisibleHtml, 'text/html')
// body 下的内容才是由 markdown 转换而来的, 不可见内容转换的 dom 集合
const editorDoms = invisibleDomAll.body.querySelectorAll(matchHtmlTags)
// 预览页面的 dom 集合

View File

@ -7,7 +7,7 @@ import markedKatex from 'marked-katex-extension'
import { markedHighlight } from 'marked-highlight'
import hljs from 'highlight.js'
// katex
import katex from 'katex'
import katex, { KatexOptions } from 'katex'
import 'katex/dist/katex.min.css'
// mermaid
import mermaid from 'mermaid'
@ -33,7 +33,7 @@ const markmapOptions = deriveOptions({
duration: 0, // 展开缩起动画
maxWidth: 160, // 每个节点最大宽度
zoom: true, // 缩放
pan: false // 拖动
pan: true // 拖动
})
/**
@ -54,7 +54,7 @@ export const singleDollar = /^\$+([^\$\n]+?)\$+/
export const doubleDollar = /(?<=\$\$).*?(?=\$\$)/
export const doubleWell = /(?<=\#\#).*?(?=\#\#)/
const katexOptions = {
const katexOptions: KatexOptions = {
throwOnError: false,
displayMode: true,
// 生成 katex-mathml 时会出现错误, mathml 绝对定位没有定到 katex-display 元素, 而是找到上级导致页面出现错误
@ -86,32 +86,32 @@ let hljsConfig = {
marked.use(markedHighlight(hljsConfig))
//#endregion
//#region ----------------------------------------< tokenizer >--------------------------------------
export const tokenizerCodespan = (src: string): any => {
const match = src.match(singleDollar)
if (match) {
let result = {
type: 'codespan',
raw: match[0],
text: match[0]
}
return result
}
return false
}
//#endregion
//#region ----------------------------------------< renderer >--------------------------------------
const domParser = new DOMParser()
/**
* TOC ,
* @param text
* @param level
* @param raw
*/
export const renderHeading = (text: any, level: number) => {
const realLevel = level
return `<h${realLevel} id="${realLevel}-${text}">${text}</h${realLevel}>`
export const renderHeading = (text: string, level: number, raw: string) => {
let id: string = randomInt(1000000, 9999999).toString()
try {
if (raw.indexOf('<') > -1 && raw.indexOf('>') > -1) {
let dom = domParser.parseFromString(raw, 'text/html')
if (dom) {
id += dom.body.innerText
} else {
id += text
}
} else {
id += text
}
} catch {
id += text
}
return `<h${level} id="${id}">${text}</h${level}>`
}
/**
@ -171,21 +171,24 @@ export const renderBlockquote = (quote: string) => {
/**
* :
* 1. bilibili
* : ```bilibili${grammar}bvid${grammar}w100${grammar}h100
* 官方使用文档: https://player.bilibili.com/
*
* 1. mermaid (async)
* 2. katex
* 3. mermaid
* 4. markmap
* 3. markmap (async)
* 4. bilibili (iframe), 文档: https://player.bilibili.com/
*
* @param code HTML
* @param language
* @param isEscaped
*/
export const renderCode = (code: string, language: string | undefined, _isEscaped: boolean) => {
export const renderCode = (code: string, language: string | undefined, _isEscaped: boolean, asyncStat: { need: number; done: number }) => {
if (language == undefined) language = 'text'
/** ==========================================================================================
* mermaid
* ```mermaid${grammar}h300
* ========================================================================================== */
if (language.startsWith('mermaid') && isNotBlank(code)) {
asyncStat.need++
const eleid = 'mermaid-' + Date.now() + '-' + randomInt(1, 10000)
const escape = escape2Html(code) as string
@ -206,10 +209,20 @@ export const renderCode = (code: string, language: string | undefined, _isEscape
.then((syntax) => {
let canSyntax: boolean | void = syntax
if (canSyntax) {
mermaid.render(eleid + '-svg', escape).then((resp) => {
mermaid.render(eleid + '-svg', escape).then(async (resp) => {
const { svg } = resp
let element = document.getElementById(eleid)
element!.innerHTML = svg
let retry = 0
while (!element || element == null) {
if (retry > 30) break
await sleep(5)
element = document.querySelector(`#${eleid}`)
retry++
}
if (element) {
element.innerHTML = svg
}
asyncStat.done++
})
}
})
@ -222,10 +235,14 @@ export const renderCode = (code: string, language: string | undefined, _isEscape
</p>`
let element = document.getElementById(eleid)
if (element) element!.innerHTML = html
asyncStat.done++
})
return `<p class="mermaid-container" style="height:${height}" id="${eleid}">${eleid}</p>`
return `<p class="mermaid-container" style="height:${height}" id="${eleid}"></p>`
}
/**
* katex
*/
if (language === 'katex') {
try {
return katex.renderToString(escape2Html(code), { throwOnError: true, displayMode: true, output: 'html' })
@ -238,7 +255,12 @@ export const renderCode = (code: string, language: string | undefined, _isEscape
}
}
/** ==========================================================================================
* markmap
* ```markmap${grammar}h300
* ========================================================================================== */
if (language.startsWith('markmap')) {
asyncStat.need++
let height = '300px'
let tags: string[] = language.split(grammar)
if (tags.length >= 2) {
@ -254,26 +276,40 @@ export const renderCode = (code: string, language: string | undefined, _isEscape
const eleid = 'markmap-' + Date.now() + '-' + randomInt(1, 10000)
const escape = escape2Html(code) as string
const { root } = transformer.transform(escape)
new Promise<SVGElement>(async (resolve, reject) => {
let svgEl: SVGElement | null = document.querySelector(`#${eleid}`)
// let svg: SVGElement = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
// svg.id = eleid
// svg.setAttributeNS(null, 'width', '100%')
// svg.setAttributeNS(null, 'height', '500px')
// svg.setAttributeNS(null, 'viewBox', '0 0 100 500')
// svg.classList.add('markmap')
// svg.style.width = '500px'
// svg.style.height = height
// let markmap = Markmap.create(svg, markmapOptions, root)
// console.log(svg.outerHTML)
// return `<p class="markmap-container">${svg.outerHTML} </p>`
new Promise<SVGElement>(async (_resolve, _reject) => {
let svg: SVGElement | null = document.querySelector(`#${eleid}`)
let retry = 0
while (!svgEl || svgEl == null) {
if (retry > 10) break
await sleep(10)
svgEl = document.querySelector(`#${eleid}`)
while (!svg || svg == null) {
if (retry > 30) break
await sleep(5)
svg = document.querySelector(`#${eleid}`)
retry++
}
if (svgEl) {
resolve(svgEl)
} else {
reject()
if (svg) {
Markmap.create(svg, markmapOptions, root)
}
}).then((svgEl: SVGElement) => {
Markmap.create(svgEl, markmapOptions, root)
asyncStat.done++
})
return `<p><svg id=${eleid} xmlns="http://www.w3.org/2000/svg" style="width:100%;height:${height}"></svg></p>`
return `<p class="markmap-container"><svg id=${eleid} xmlns="http://www.w3.org/2000/svg" style="width:100%;height:${height}"></svg></p>`
}
/** ==========================================================================================
* bilibili
* ```bilibili${grammar}bvid${grammar}w100${grammar}h100
* ========================================================================================== */
if (language.startsWith('bilibili')) {
let bvid = ''
let width = '100%'
@ -295,7 +331,7 @@ export const renderCode = (code: string, language: string | undefined, _isEscape
if (tag.startsWith('h')) {
height = tags[i].substring(1)
if (!height.endsWith('%')) {
width += 'px'
height += 'px'
}
}
}
@ -309,9 +345,10 @@ export const renderCode = (code: string, language: string | undefined, _isEscape
}
return `<iframe width="${width}" height="${height}" style="margin: 10px 0"
scrolling="no" border="0" frameborder="no" framespacing="0"
scrolling="no" border="0" frameborder="no" framespacing="0" loading="lazy"
src="https://player.bilibili.com/player.html?bvid=${bvid}&page=1&autoplay=0" ></iframe>`
}
const id = 'pre-' + Date.now() + '-' + randomInt(1, 1000000)
const lines: string[] = code.split(/\n|\r\n?|\n\n+/g)
let result = '<ol>'
@ -328,25 +365,10 @@ export const renderCode = (code: string, language: string | undefined, _isEscape
/**
*
* 1. katex `$内部写表达式$`
* @param src
* @returns
*/
export const renderCodespan = (src: string) => {
let arr = src.match(singleDollar)
if (arr != null && arr.length > 0) {
try {
return katex.renderToString(arr[1], {
throwOnError: true,
output: 'html'
})
} catch (error) {
console.error(error)
return `<div class='bl-preview-analysis-fail-inline'>
Katex ! <a href='https://katex.org/#demo' target='_blank'> Katex </a>
</div>`
}
}
return `<code>${src}</code>`
}
@ -397,7 +419,12 @@ export const renderImage = (href: string | null, title: string | null, text: str
* ref: 双链内容
* }
*/
export const renderLink = (href: string | null, title: string | null, text: string, docTrees: DocTree[]) => {
export const renderLink = (
href: string,
title: string | null | undefined,
text: string,
docTrees: DocTree[]
): { link: string; ref: ArticleReference } => {
let link: string
let ref: ArticleReference = { targetId: '0', targetName: text, targetUrl: href as string, type: 21 }
if (isBlank(title)) {
@ -413,14 +440,18 @@ export const renderLink = (href: string | null, title: string | null, text: stri
}
// 从文章列表中获取文章, 如果找到则认为是内部引用, 否则即使是内部引用格式, 也认为是个外部文章.
// 内部引用不会使用 Markdown 中的链接名, 而是用内部文章名
let article = getDocById(articleId.toString(), docTrees)
if (article != undefined) {
ref.targetId = article.i
ref.targetName = article.n
ref.type = 11
} else {
ref.targetId = articleId.toString()
ref.targetName = '未知文章-' + articleId.toString()
ref.type = 12
}
// class="inner-link bl-tip bl-tip-bottom" data-tip="双链引用: 《${text}》"
link = `<a target="_blank" href=${href} class="inner-link"
onclick="onHtmlEventDispatch(this,'',event,'showArticleReferenceView','${ref.targetId}')">${text}</a>`
} else {
@ -460,21 +491,14 @@ const simpleRenderer = {
}
let lineNumbers = result + '</ol>'
return `<pre><code class="hljs language-${language}"></code>${lineNumbers}<div class="pre-copy">${language}</div></pre>`
// return `<pre><code class="hljs language-${language}">${code}</code></pre>`
},
codespan(src: string): string {
return renderCodespan(src)
}
}
const tokenizer = {
codespan(src: string): any {
return tokenizerCodespan(src)
}
}
//@ts-ignore
simpleMarked.use({ tokenizer: tokenizer, renderer: simpleRenderer })
simpleMarked.use({ renderer: simpleRenderer })
//#endregion

View File

@ -1,16 +1,25 @@
.el-backtop {
@include box(37px, 37px);
box-shadow: none;
background-color: transparent;
}
.backtop {
@include box(100%, 100%);
@include themeShadow(0 0 5px 1px #cecece, 0 0 5px 1px #000000);
@include themeColor(#c2c2c2, #717171);
@include themeShadow(0 0 3px 1px #0000001e, 0 0 3px 1px #000000);
color: var(--bl-text-color-light);
background-color: var(--bl-html-color);
text-align: center;
line-height: 40px;
line-height: 38px;
border-radius: 8px;
font-size: 25px;
transform: rotate(-90deg);
transition: color 0.3s;
font-size: 20px;
transition: color 0.2s;
&:hover {
color: var(--el-color-primary);
}
}
.backbottom {
@extend .backtop;
}

View File

@ -85,7 +85,7 @@
.gutter-holder {
height: 100%;
width: 50px;
width: 49px;
position: absolute;
background-color: var(--bl-editor-gutters-bg-color);
border-right: 1px solid var(--bl-editor-gutters-border-color);
@ -129,11 +129,71 @@
}
}
.operator {
@include themeBorder(2px, #d8d8d841, #ffffff12);
width: 20px;
height: 75px;
padding-top: 5px;
background-color: var(--bl-html-color);
border-top-right-radius: 8px;
border-bottom-right-radius: 8px;
position: absolute;
bottom: 10%;
left: calc(50% - 0.5px);
z-index: 4;
text-align: center;
transition: border 0.3s;
cursor: pointer;
&:hover {
border: 2px solid var(--el-border-color);
.iconbl {
color: var(--el-border-color);
}
}
.iconbl {
@include themeColor(#d8d8d841, #ffffff12);
line-height: 20px;
font-size: 16px;
transition: color 0.3s;
&:hover {
color: var(--el-color-primary-light-3);
}
&:active {
transform: scale(0.9);
}
}
.bl-admonish-line {
font-size: 14px;
}
.bl-refresh-line {
font-size: 14px;
}
.bl-eye-line {
font-size: 18px;
margin-bottom: 7px;
transform: rotate(90deg);
&:active {
transform: scale(0.9) rotate(90deg);
}
}
.bl-scroll {
font-size: 19px;
margin-left: -2px;
margin-bottom: 2px;
}
}
.resize-divider {
height: 100%;
border-left: 2px solid var(--el-border-color);
margin-left: 1px;
z-index: 3;
z-index: 4;
position: relative;
cursor: ew-resize;
&:hover {
@ -146,7 +206,7 @@
overflow-y: scroll;
overflow-x: hidden;
word-wrap: break-word;
padding: 10px 20px 0 20px;
padding: 10px 20px 0 30px;
z-index: 3;
font-family: inherit;
font-size: inherit;
@ -154,8 +214,6 @@
:deep(.katex > *) {
font-size: 1.2em !important;
font-family: 'KaTeX_Size1', sans-serif !important;
// font-size: 1.3em !important;
// font-family: 'KaTeX_Math', sans-serif !important;
}
}
}
@ -165,6 +223,5 @@
border-top: 1px solid var(--el-border-color);
z-index: 2;
}
}
}

View File

@ -7,7 +7,6 @@
.toc-content {
overflow-y: overlay;
padding-top: 10px;
.toc-1,
.toc-2,
@ -38,33 +37,23 @@
}
.toc-2 {
&::before {
content: ' ';
}
padding-left: 10px;
}
.toc-3 {
&::before {
content: ' ';
}
padding-left: 20px;
}
.toc-4 {
&::before {
content: ' ';
}
padding-left: 30px;
}
.toc-5 {
&::before {
content: ' ';
}
padding-left: 40px;
}
.toc-6 {
&::before {
content: ' ';
}
padding-left: 50px;
}
}
}
@ -94,8 +83,9 @@
}
.toc-content {
@include box(100%, calc(100% - 20px - 20px));
@include font(14px);
@include box(100%, calc(100% - 130px));
@include font(13px);
padding-bottom: 10px;
.toc-1 {
border-top: 2px solid var(--bl-preview-toc-border-color);

View File

@ -1,3 +1,4 @@
import { isNull } from '@renderer/assets/utils/obj'
import Notify from '@renderer/scripts/notify'
import { isEmpty } from 'lodash'
import { Ref } from 'vue'
@ -36,6 +37,21 @@ export enum SortLevelColor {
FOUR = '#4AA40E93'
}
/**
*
* @param doc
* @returns true: ; false:
*/
export const isArticle = (doc: DocInfo | undefined): boolean => {
if (isNull(doc)) {
return false
}
if (isNull(doc!.type) || doc!.type != 3) {
return false
}
return true
}
/**
*
* @param level

View File

@ -1,7 +1,7 @@
<template>
<div class="aside-upload-root">
<el-tooltip effect="light" placement="right-start" :show-after="1000" :hide-after="0">
<template #content>提示:<br />从这里上传, 会上传至<br />🌌 默认文件夹</template>
<el-tooltip effect="light" placement="right" :show-after="1000" :hide-after="0">
<template #content>从这里上传, 会上传至<br />🌌 默认文件夹</template>
<el-upload
name="file"
:action="serverStore.serverUrl + uploadFileApiUrl"

View File

@ -32,9 +32,9 @@
<div class="conf-tip">博客左上角 Logo 的访问地址以及在浏览器标签中的 Logo</div>
</el-form-item>
<el-form-item label="IPC备案号">
<el-form-item label="ICP备案号">
<el-input size="default" v-model="userParamForm.WEB_IPC_BEI_AN_HAO" @change="(cur: any) => updParam('WEB_IPC_BEI_AN_HAO', cur)"></el-input>
<div class="conf-tip">如果博客作为你的域名首页你可能需要配置 IPC 备案号</div>
<div class="conf-tip">如果博客作为你的域名首页你可能需要配置 ICP 备案号</div>
</el-form-item>
<el-form-item label="公网安备号">

View File

@ -69,7 +69,7 @@
:rows="2"
resize="none"
v-model="customBlogUrl"
placeholder="示例: http://www.xxx.com/#/articles?articleId="
placeholder="示例: http://www.google.com//#/articles?articleId="
@input="customBlogUrlChange"></el-input>
</bl-row>
<div class="blog-url-error" style="margin-top: 10px">

View File

@ -81,7 +81,7 @@
<bl-col class="desc" align="flex-end">
<div>修改主题后, 再次切换日间/夜间模式可查看完整效果</div>
<div>
<a href="https://chinesecolor.org/" target="_blank">主题色参考 <span class="iconbl bl-sendmail-line"></span></a>
<a href="https://www.chinavid.com/color.html" target="_blank">主题色参考 <span class="iconbl bl-sendmail-line"></span></a>
</div>
</bl-col>
</el-tab-pane>

View File

@ -7,7 +7,7 @@
<div class="task-collapse">
<el-collapse v-model="activeName" accordion>
<el-collapse-item title="每日待办事项" name="1" class="collapse-item">
<el-calendar ref="CalendarRef" class="task-day-calendar" v-model="selectDay">
<el-calendar ref="CalendarRef" class="task-day-calendar">
<template #header="{ date }">
<bl-row just="space-between" class="header" style="margin: 8px 10px">
<div class="month">{{ date.split(' ')[2] }}{{ date.split(' ')[3] }}</div>
@ -21,7 +21,7 @@
<template #date-cell="{ data }">
<div class="cell-wrapper" @click="toTask(data.day, data.day, 10)">
<div class="day">{{ data.day.split('-')[2] }}</div>
<div v-if="getCount(data.day) > 0">
<div v-if="getCount(data.day) != 0">
<bl-tag>{{ getCount(data.day) }}</bl-tag>
</div>
</div>
@ -35,13 +35,13 @@
<!-- update name -->
<el-input
v-if="phased.updTodoName"
:id="'phased-name-input-' + phased.todoId"
v-model="phased.todoName"
type="textarea"
:id="'phased-name-input-' + phased.todoId"
:rows="3"
@blur="blurPhasedUpdHandle(phased.todoId!)"></el-input>
<div v-else @dblclick="showPhasedUpdHandle(phased.todoId!)">{{ phased.todoName }}</div>
<bl-tag v-if="phased.taskCount > 0">{{ phased.taskCount }}</bl-tag>
<bl-tag>{{ phased.taskCountStat }}</bl-tag>
</div>
<!-- add phased -->
@ -49,6 +49,7 @@
v-if="showPhasedAdd"
ref="phasedAddInputRef"
v-model="phasedAddName"
@keyup.enter="blurPhasedAddHandle"
@blur="blurPhasedAddHandle"
style="margin-top: 10px"></el-input>
<div v-else class="task-phased-add" @click="showPhasedAddHandle">新增计划</div>
@ -58,7 +59,7 @@
<el-collapse-item title="阶段性事项 已完成" name="3">
<div v-for="phased in todoPhasedClose" class="task-phased" @click="toTask(phased.todoId, phased.todoName, phased.todoType)">
{{ phased.todoName }}
<bl-tag v-if="phased.taskCount > 0">{{ phased.taskCount }}</bl-tag>
<bl-tag>{{ phased.taskCountStat }}</bl-tag>
</div>
</el-collapse-item>
</el-collapse>
@ -101,8 +102,6 @@ useLifecycle(
)
//#region ----------------------------------------< >--------------------------------------
const selectDay = ref()
const CalendarRef = ref<CalendarInstance>()
const selectDate = (val: CalendarDateType) => {
if (!CalendarRef.value) return
@ -128,6 +127,7 @@ const getTodos = () => {
todoType: 10,
today: false,
taskCount: todo.taskCount > 0 ? todo.taskCount : 0,
taskCountStat: '0|0|0',
updTodoName: false
})
}
@ -136,9 +136,17 @@ const getTodos = () => {
})
}
/**
* @todo 日历每一项点击时由于内部数据变更, 都会触发查询
* @param day
*/
const getCount = (day: string): number => {
if (!todoDayMaps.value) return 0
if (!todoDayMaps.value.get(day)) return 0
if (!todoDayMaps.value) {
return 0
}
if (!todoDayMaps.value.has(day)) {
return 0
}
return todoDayMaps.value.get(day)!.taskCount
}
@ -316,6 +324,7 @@ const blurPhasedUpdHandle = (todoId: string) => {
.task-phased {
@include flex(row, flex-start, center);
@include font(13px, 300);
flex-wrap: wrap;
padding: 1px 3px;
border-radius: 4px;
color: var(--bl-text-color);
@ -327,6 +336,7 @@ const blurPhasedUpdHandle = (todoId: string) => {
&:first-child {
margin-top: 5px;
word-break: break-all;
}
&:last-child {

View File

@ -54,6 +54,10 @@ export interface TodoList {
*
*/
taskCount: number
/**
*
*/
taskCountStat: string
}
/**

View File

@ -105,7 +105,7 @@
.add-icon {
@include font(13px, 300);
padding-right: 3px;
padding: 0 3px;
color: var(--bl-text-color-light);
cursor: pointer;
@ -169,10 +169,10 @@
border: 3px solid var(--el-color-primary);
.iconbl {
@include themeColor(#e4e4e4, #2c2c2c);
display: inherit;
margin-top: 40px;
font-size: 90px;
color: #e4e4e4;
}
}
}

View File

@ -113,7 +113,7 @@
</div>
<div class="toc-title">目录</div>
<div class="toc-content">
<div v-for="toc in tocList" :key="toc.index" :class="[toc.clazz]" @click="toScroll(toc.level, toc.content)">
<div v-for="toc in tocList" :key="toc.id" :class="[toc.clazz]" @click="toScroll(toc.id)">
{{ toc.content }}
</div>
</div>
@ -185,7 +185,7 @@ const article = ref<DocInfo>({
html: `<div style="color:#E3E3E3;width:100%;height:300px;display:flex;justify-content: center;
align-items: center;font-size:25px;">请在左侧菜单选择文章</div>`
})
const tocList = ref<any>([])
const tocList = ref<Toc[]>([])
const defaultOpeneds = ref<string[]>([])
const PreviewRef = ref()
@ -217,6 +217,10 @@ const getDocTree = () => {
}
}
/**
* 获取文章信息
* @param tree
*/
const clickCurDoc = async (tree: DocTree) => {
// , ,
if (tree.ty == 3) {
@ -224,6 +228,7 @@ const clickCurDoc = async (tree: DocTree) => {
window.history.replaceState('', '', '#/articles?articleId=' + tree.i)
nextTick(() => {
PreviewRef.value.scrollTo({ top: 0 })
parseTocAsync(PreviewRef.value)
})
}
}
@ -240,19 +245,56 @@ const getCurEditArticle = async (id: number) => {
}
const then = (resp: any) => {
if (isNull(resp.data)) return
if (isNull(resp.data)) {
return
}
article.value = resp.data
tocList.value = JSON.parse(resp.data.toc)
}
if (userStore.isLogin) {
await articleInfoApi({ id: id, showToc: true, showMarkdown: false, showHtml: true }).then((resp) => then(resp))
await articleInfoApi({ id: id, showToc: false, showMarkdown: false, showHtml: true }).then((resp) => then(resp))
} else {
await articleInfoOpenApi({ id: id, showToc: true, showMarkdown: false, showHtml: true }).then((resp) => then(resp))
await articleInfoOpenApi({ id: id, showToc: false, showMarkdown: false, showHtml: true }).then((resp) => then(resp))
}
}
const toScroll = (level: number, content: string) => {
let id = level + '-' + content
/**
* 解析目录
*/
const parseTocAsync = async (ele: HTMLElement) => {
let heads = ele.querySelectorAll('h1, h2, h3, h4, h5, h6')
let tocs: Toc[] = []
for (let i = 0; i < heads.length; i++) {
let head: Element = heads[i]
let level = 1
let content = (head as HTMLElement).innerText
let id = head.id
switch (head.localName) {
case 'h1':
level = 1
break
case 'h2':
level = 2
break
case 'h3':
level = 3
break
case 'h4':
level = 4
break
case 'h5':
level = 5
break
case 'h6':
level = 6
break
}
let toc: Toc = { content: content, clazz: 'toc-' + level, id: id }
tocs.push(toc)
}
tocList.value = tocs
}
const toScroll = (id: string) => {
let elm = document.getElementById(id)
elm?.scrollIntoView(true)
}
@ -349,6 +391,9 @@ const closeAll = () => {
maskStyle.value = { display: 'none' }
}
/**
*
*/
const onresize = () => {
let width = document.body.clientWidth
if (width < 1100) {
@ -552,10 +597,6 @@ const onresize = () => {
.toc-5,
.toc-6 {
cursor: pointer;
// overflow: hidden;
// white-space: nowrap;
// text-overflow: ellipsis;
// white-space: pre;
&:hover {
font-weight: bold;
@ -564,7 +605,6 @@ const onresize = () => {
.toc-1 {
font-size: 1.1em;
border-top: 2px solid #eeeeee;
margin-top: 5px;
padding-top: 5px;

View File

@ -53,3 +53,12 @@ declare type DocType = 1 | 2 | 3
declare interface Window {
onHtmlEventDispatch: any
}
/**
*
*/
declare interface Toc {
content: string
clazz: string
id: string
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

View File

@ -7,7 +7,6 @@ docker
├─ build 镜像构建
| └─ Dockerfile 应用镜像构建脚本
└─ compose docker compose 配置
├─ blossom.yaml 包含后台应用 Blossom-backend
└─ blossom-mysql8.yaml 包含后台应用 Blossom-backend 与 MySql8
```
@ -68,12 +67,6 @@ docker run -d \
-v /home/bl/:/home/bl/ \
# 启动的镜像名称
jasminexzzz/blossom:latest \
# 使用的后台配置文件,不需要修改
--spring.profiles.active=prod \
# 配置图片上传后对应生成的访问URL需要以/pic/结尾。注意该访问URL需要与访问后台的域名和端口相同
--project.iaas.blos.domain="http://127.0.0.1:9999/pic/" \
# 配置图片保存的磁盘路径,请使用 -v 将该路径挂载为本地路径,如上方示例
--project.iaas.blos.default-path="/home/bl/img/" \
# 配置数据库访问地址
--spring.datasource.url="jdbc:mysql://192.168.31.99:3306/blossom?useUnicode=true&characterEncoding=utf-8&allowPublicKeyRetrieval=true&allowMultiQueries=true&useSSL=false&&serverTimezone=GMT%2B8" \
# 配置数据库用户名
@ -92,55 +85,7 @@ docker run -d --name blossom-backend -p 9999:9999 -v /home/bl/:/home/bl/ jasmine
可以使用 docker compose 单独拉取应用镜像,或者连同 MySQL 一起拉取构建。下列示例均可在项目`/docker/compose`目录下查看
## 1. 只拉取应用镜像示例
该 docker compose 不包含 MySQL需要你自行安装 MySQL并在 MySQL 中先创建一个数据库数据库名称需要与启动容器命令中参数SPRING_DATASOURCE_URL配置的数据库名称相同如果不需要自定义数据库名称你可以直接使用如下语句创建数据库
```sql
CREATE DATABASE `blossom` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin;
```
docker compose 文件示例
```yml
version: "3.8"
services:
blossom:
image: jasminexzzz/blossom:latest
container_name: blossom-backend
volumes:
# 【需修改】挂载图片保存路径如果是windows环境可以使用/c/home/bl/来指定磁盘
- /d/blossom/bl/:/home/bl/
environment:
SPRING_PROFILES_ACTIVE: prod
# 【需修改】配置数据库访问地址
SPRING_DATASOURCE_URL: jdbc:mysql://192.168.31.99:3306/blossom?useUnicode=true&characterEncoding=utf-8&allowPublicKeyRetrieval=true&allowMultiQueries=true&useSSL=false&&serverTimezone=GMT%2B8
# 【需修改】配置数据库用户名
SPRING_DATASOURCE_USERNAME: root
# 【需修改】配置数据库密码
SPRING_DATASOURCE_PASSWORD: jasmine888
# 【需修改】配置图片上传后对应生成的访问 URL需要以/pic/结尾。注意,该访问 URL 需要与访问后台的域名和端口相同
PROJECT_IAAS_BLOS_DOMAIN: http://localhost:9999/pic/
# 配置图片保存的磁盘路径,并在 volumes 中将该路径挂载为本地路径,如上方 volumes 中的示例
PROJECT_IAAS_BLOS_DEFAULT-PATH: /home/bl/img/
ports:
- "9999:9999"
restart: always
```
启动不含有 MYSQL 的 Docker Compose
```bash
docker compose -f docker/compose/blossom.yaml up -d
```
> 该方式与使用公共镜像基本相同
---
## 2. 拉取应用镜像与 MySQL 镜像示例
## 1. 拉取应用镜像与 MySQL 镜像示例
该 docker compose 包含 MySQLMySQL 容器在初始化时会自动创建数据库 Blossom但你需要挂载 MySQL 文件到宿主机,防止数据丢失。
@ -160,17 +105,12 @@ services:
# 【需修改】挂载图片保存路径如果是windows环境可以使用/c/home/bl/img/来指定磁盘
- /d/blossom/bl/:/home/bl/
environment:
SPRING_PROFILES_ACTIVE: prod
# 配置数据库访问地址
SPRING_DATASOURCE_URL: jdbc:mysql://blmysql:3306/blossom?useUnicode=true&characterEncoding=utf-8&allowPublicKeyRetrieval=true&allowMultiQueries=true&useSSL=false&&serverTimezone=GMT%2B8
# 【需修改】配置数据库用户名
SPRING_DATASOURCE_USERNAME: root
# 【需修改】配置数据库密码
SPRING_DATASOURCE_PASSWORD: jasmine888
# 【需修改】配置图片上传后对应生成的访问 URL需要以/pic/结尾。注意,该访问域名(IP:端口)需要与访问后台的域名(IP:端口)相同
PROJECT_IAAS_BLOS_DOMAIN: http://localhost:9999/pic/
# 配置图片保存的磁盘路径,并在 volumes 中将该路径挂载为本地路径,如上方 volumes 中的示例
PROJECT_IAAS_BLOS_DEFAULT-PATH: /home/bl/img/
ports:
- "9999:9999"
networks:
@ -218,127 +158,6 @@ services:
docker compose -f docker/compose/blossom-mysql8.yaml up -d
```
# 三、使用 Dockerfile 构建应用镜像
> 该方式构建镜像只包含后台应用,不包含数据库在内
## 1. 后台工程打包
需要安装如下工具
- Docker 运行环境
- git 运行环境
- Maven 3.6.1+
克隆代码仓库,然后进入到`blossom-backend/`目录下,运行如下命令编译打包后台工程
```bash
git clone https://github.com/blossom-editor/blossom.git
maven clean package
```
打包成功如下图:
![backend_package](../doc/imgs/deploy/backend_package.png)
进入到`blossom-backend\backend\target`路径下检查是否包含`backend-blossom.jar`文件,如果包含则打包成功。
## 2. 构建本地镜像
进入到项目根目录下,运行如下命令
```bash
docker build -t <自定义前缀>/<自定义镜像名称>:<自定义镜像标签> -f docker/build/Dockerfile .
# 示例1
docker build -t self/blossom:self -f docker/build/Dockerfile .
```
> [!WARNING]
> 注意:一定要在项目根目录下运行`docker build`命令,否则无法找到后台 jar 包文件
> [!NOTE]
> 在后台项目`blossom-backend\`路径下也包含一个 Dockerfile 文件,只有构建命令略有不同,具体参阅`blossom-backend\README.md`文件
## 3. 创建数据库
你需要在 MySQL 中先创建一个数据库,数据库名称需要与启动容器命令中参数--spring.datasource.url配置的数据库名称相同如果不需要自定义数据库名称你可以直接使用如下语句创建数据库
```sql
CREATE DATABASE `blossom` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin;
```
## 4. 启动容器
```bash
docker run -d \
# 容器名称
--name blossom-backend \
# 指定端口映射
-p 9999:9999 \
# 挂载图片保存路径如果是windows环境可以使用/c/home/bl/来指定磁盘
-v /home/bl/:/home/bl/ \
# 启动的镜像名称
jasminexzzz/blossom:latest \
# 使用的后台配置文件,不需要修改
--spring.profiles.active=prod \
# 配置图片上传后对应生成的访问URL需要以/pic/结尾。注意该访问URL需要与访问后台的域名和端口相同
--project.iaas.blos.domain="http://127.0.0.1:9999/pic/" \
# 配置图片保存的磁盘路径,请使用 -v 将该路径挂载为本地路径,如上方示例
--project.iaas.blos.default-path="/home/bl/img/" \
# 配置数据库访问地址
--spring.datasource.url="jdbc:mysql://192.168.31.99:3306/blossom?useUnicode=true&characterEncoding=utf-8&allowPublicKeyRetrieval=true&allowMultiQueries=true&useSSL=false&&serverTimezone=GMT%2B8" \
# 配置数据库用户名
--spring.datasource.username=root \
# 配置数据库密码
--spring.datasource.password=jasmine888
```
# 四、使用 Idea Cloud Toolkit 插件部署
> 该部署方式适合修改代码后部署在物理机器中。
在 Idea 中安装插件
![idea_plugin1](../doc/imgs/deploy/idea_plugin1.png)
配置目标机器地址
![idea_plugin1](../doc/imgs/deploy/idea_plugin2.png)
![idea_plugin1](../doc/imgs/deploy/idea_plugin3.png)
新增启动项
![idea_plugin1](../doc/imgs/deploy/idea_plugin4.png)
配置启动项
![idea_plugin1](../doc/imgs/deploy/idea_plugin5.png)
1. 需要上传的`jar`包地址
2. 目标机器的IP点击右侧+号添加
3. 上传到目标机器的路径
4. 上传后执行的命令,例如执行下方的重启脚本
5. 上传前执行的命令
重启脚本示例`restart-sprigboot.sh`
```bash
#!/dash
# 重启 blossom
pid=`ps aux | grep backend-blossom.jar | grep -v grep | awk '{print $2}'`
echo "进程ID : " $pid
kill -9 $pid
echo "进程" $pid "已被杀死"
echo "开始重启 backend-blossom 服务器"
# 注意修改对应路径
nohup java -Xms1024m -Xmx1024m -jar /usr/local/jasmine/blossom/backend/backend-blossom.jar &
echo "backend-blossom 正在启动,请查看日志 ↓↓↓↓↓↓↓↓↓↓↓↓↓↓"
```

View File

@ -1,23 +0,0 @@
version: "3.8"
services:
blossom:
image: jasminexzzz/blossom:latest
container_name: blossom-backend
volumes:
# 【需修改】挂载图片保存路径如果是windows环境可以使用/c/home/bl/img/来指定磁盘
- /d/blossom/bl/:/home/bl/
environment:
SPRING_PROFILES_ACTIVE: prod
# 【需修改】配置数据库访问地址
SPRING_DATASOURCE_URL: jdbc:mysql://192.168.31.99:3306/blossom?useUnicode=true&characterEncoding=utf-8&allowPublicKeyRetrieval=true&allowMultiQueries=true&useSSL=false&&serverTimezone=GMT%2B8
# 【需修改】配置数据库用户名
SPRING_DATASOURCE_USERNAME: root
# 【需修改】配置数据库密码
SPRING_DATASOURCE_PASSWORD: jasmine888
# 【需修改】配置图片上传后对应生成的访问 URL需要以/pic/结尾。注意,该访问 URL 需要与访问后台的域名和端口相同
PROJECT_IAAS_BLOS_DOMAIN: http://localhost:9999/pic/
# 配置图片保存的磁盘路径,并在 volumes 中将该路径挂载为本地路径,如上方 volumes 中的示例
PROJECT_IAAS_BLOS_DEFAULT-PATH: /home/bl/img/
ports:
- "9999:9999"
restart: always