Compare commits
10
Commits
efea624376
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ccbb924c9a | ||
|
|
98ab992728 | ||
|
|
175838f7c5 | ||
|
|
68d9946b70 | ||
|
|
763783b984 | ||
|
|
5302ad9798 | ||
|
|
7c937a090f | ||
|
|
bb1fdb5547 | ||
|
|
e5473eb499 | ||
|
|
f0ba55fe25 |
+1
-1
@@ -7,7 +7,7 @@ module.exports = {
|
|||||||
"extends": [
|
"extends": [
|
||||||
"eslint:recommended",
|
"eslint:recommended",
|
||||||
"plugin:@typescript-eslint/recommended",
|
"plugin:@typescript-eslint/recommended",
|
||||||
"plugin:vue/vue3-essential",
|
"plugin:vue/essential",
|
||||||
"./.eslintrc-auto-import.json"
|
"./.eslintrc-auto-import.json"
|
||||||
],
|
],
|
||||||
"overrides": [
|
"overrides": [
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# 2026-08-06 工作日志
|
||||||
|
|
||||||
|
## TypeScript enum 重构(全量消除 enum)
|
||||||
|
用户决定改用纯联合类型,自行修改依赖方。全项目 18 处 enum 分两类处理:
|
||||||
|
|
||||||
|
### 改为纯联合类型(字符串枚举,仅作类型用)
|
||||||
|
- `Attachment.ts` AttachmentBizType、`Template.ts` TemplateBizType
|
||||||
|
- `Model.ts` RunEnv / OrderType / ImageType(ImageType 值为 ir-auto 等,与 key 不同)
|
||||||
|
- `Comment.ts` CommentReplyBizType / CommentBizType
|
||||||
|
- `User.ts` UserAttachType(在 UserAPI.ts 有值引用,已同步改为字符串字面量,import 改 type-only)
|
||||||
|
|
||||||
|
### 改为 const 对象 + 派生类型(数字枚举或运行时值引用,纯联合不可行)
|
||||||
|
- `User.ts` LoginType、`Article.ts` ArticleType / ArticleAttachType / ArticleSoftwareDownloadType / ArticleSoftwareRuntime(数字枚举,保留数值避免破坏数据契约)
|
||||||
|
- `deviceStore.ts` Breakpoints(数字,用于比较 width < Breakpoints.SM)
|
||||||
|
- `IOSize.ts` Unit(用 Object.keys/values + Unit.KB 值引用)
|
||||||
|
- `Prismjs.ts` PrismjsType / PrismjsViewer(Map 键值引用)
|
||||||
|
- `directives/Popup.ts` PopupType(switch case 值引用)
|
||||||
|
|
||||||
|
### 结论
|
||||||
|
- `vue-tsc --noEmit` 通过(exit 0)
|
||||||
|
- ESLint 因项目缺 eslint.config.js(仍用旧 .eslintrc,ESLint9 不支持)无法运行,属项目既有配置问题
|
||||||
|
- 判断依据:纯联合无运行时存在,凡 `Object.keys(enum)`、`map.set(Enum.X)`、`case Enum.X`、`Enum.X` 作值的都无法纯联合,必须 const 对象
|
||||||
|
|
||||||
|
## Storage.ts 设计修正
|
||||||
|
- `getString` 返回 `string | null`(对齐 `localStorage.getItem` 契约),不再 throw
|
||||||
|
- `getJSON` 返回 `unknown`,键不存在时返回 `null`,值无法解析时才抛错
|
||||||
|
- `getObject` 返回 `T | null`,不再 throw(修正原 JSDoc 已写 `T | undefined` 但代码 throw 的矛盾)
|
||||||
|
- `is` / `not` 键不存在时自然返回 `false` / `true`,不再崩
|
||||||
|
- `has` 去掉死代码 `!== undefined`(localStorage.getItem 只返回 string|null),单次查询
|
||||||
|
- `vue-tsc --noEmit` 通过
|
||||||
|
|
||||||
|
## Toolkit.ts debounce 修复
|
||||||
|
- **Bug 修复**:`cancel()` 原先只清定时器不重置 `leading` 状态,导致立即模式下 cancel 后首次调用丢失 leading 触发
|
||||||
|
- **类型修复**:返回类型从 `T & { cancel(): void }` 改为 `((...args: Parameters<T>) => void) & { cancel(): void }`,防抖函数实际返回 void 而非 T 的返回值
|
||||||
|
- 补充完整 JSDoc(含 `@example`),说明立即模式(leading + trailing)和延迟模式(trailing only)的行为差异
|
||||||
|
- 状态变量 `immediate` → `leading`,参数 `defaultImmediate` → `immediate`,语义更清晰
|
||||||
|
- 去掉 `eslint-disable-next-line` 和多余注释
|
||||||
|
- `vue-tsc --noEmit` 通过
|
||||||
+6
-9
@@ -1,5 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="root">
|
<div class="root">
|
||||||
|
<pass-time-label :timestamp="1786694772700" underline />
|
||||||
|
<br>
|
||||||
|
<pass-time-label :timestamp="1786694772700" only-date />
|
||||||
|
<br>
|
||||||
|
<pass-time-label :timestamp="1786694772700" only-time />
|
||||||
<copyright />
|
<copyright />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -7,17 +12,9 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import {axios, Copyright, SettingMapper} from "timi-web";
|
import {axios, Copyright, SettingMapper} from "timi-web";
|
||||||
import CommonAPI from "../src/api/CommonAPI";
|
import CommonAPI from "../src/api/CommonAPI";
|
||||||
|
import PassTimeLabel from "../src/components/passtime-label/index.vue";
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const result = await CommonAPI.settingMap({
|
|
||||||
"SYSTEM": [
|
|
||||||
"SETTING_TTL"
|
|
||||||
]
|
|
||||||
});
|
|
||||||
SettingMapper.appendSettingMap(result);
|
|
||||||
|
|
||||||
|
|
||||||
console.log(SettingMapper.getValue("SYSTEM", "SETTING_TTL").value);
|
|
||||||
})
|
})
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
+21
-1
@@ -39,6 +39,24 @@
|
|||||||
"node": ">=16.0.0"
|
"node": ">=16.0.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@tiptap/extension-code-block": "^3.22.5",
|
||||||
|
"@tiptap/extension-color": "^3.22.4",
|
||||||
|
"@tiptap/extension-image": "^3.22.4",
|
||||||
|
"@tiptap/extension-link": "^3.22.4",
|
||||||
|
"@tiptap/extension-placeholder": "^3.22.4",
|
||||||
|
"@tiptap/extension-subscript": "^3.22.4",
|
||||||
|
"@tiptap/extension-superscript": "^3.22.4",
|
||||||
|
"@tiptap/extension-table": "^3.22.5",
|
||||||
|
"@tiptap/extension-table-cell": "^3.22.5",
|
||||||
|
"@tiptap/extension-table-header": "^3.22.5",
|
||||||
|
"@tiptap/extension-table-row": "^3.22.5",
|
||||||
|
"@tiptap/extension-text-align": "^3.22.4",
|
||||||
|
"@tiptap/extension-text-style": "^3.22.4",
|
||||||
|
"@tiptap/extension-underline": "^3.22.4",
|
||||||
|
"@tiptap/pm": "^3.30.2",
|
||||||
|
"@tiptap/starter-kit": "^3.22.4",
|
||||||
|
"@tiptap/vue-3": "^3.22.4",
|
||||||
|
"@marsio/vue-split-pane": "^1.0.0",
|
||||||
"axios": "1.18.0",
|
"axios": "1.18.0",
|
||||||
"less": "4.5.1",
|
"less": "4.5.1",
|
||||||
"pinia": "^3.0.2",
|
"pinia": "^3.0.2",
|
||||||
@@ -50,6 +68,7 @@
|
|||||||
"terser": "^5.44.1"
|
"terser": "^5.44.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/marked": "^6.0.0",
|
||||||
"@types/node": "^25.0.8",
|
"@types/node": "^25.0.8",
|
||||||
"@types/prismjs": "1.26.5",
|
"@types/prismjs": "1.26.5",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.53.0",
|
"@typescript-eslint/eslint-plugin": "^8.53.0",
|
||||||
@@ -74,6 +93,7 @@
|
|||||||
"vitest": "^4.1.4",
|
"vitest": "^4.1.4",
|
||||||
"vue": "^3.5.26",
|
"vue": "^3.5.26",
|
||||||
"vue-tsc": "^3.2.2",
|
"vue-tsc": "^3.2.2",
|
||||||
"vue-eslint-parser": "^10.0.0"
|
"vue-eslint-parser": "^10.0.0",
|
||||||
|
"tdesign-vue-next": "^1.20.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+960
File diff suppressed because it is too large
Load Diff
@@ -1,27 +1,25 @@
|
|||||||
import { ArticleView } from "../types";
|
import type { Article } from "../types";
|
||||||
import { axios } from "./BaseAPI";
|
import { axios } from "./BaseAPI";
|
||||||
|
|
||||||
|
export const ArticleAPI = {
|
||||||
/**
|
/**
|
||||||
* 获取文章
|
* 获取文章
|
||||||
*
|
*
|
||||||
* @param id 文章 ID
|
* @param id 文章 ID
|
||||||
* @returns 文章数据
|
* @returns 文章数据
|
||||||
*/
|
*/
|
||||||
async function view(id: string): Promise<ArticleView<any>> {
|
async view(id: string): Promise<Article> {
|
||||||
return axios.get(`/article/${id}`);
|
return axios.get(`/article/${id}`);
|
||||||
}
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 喜欢文章,后端有限调用,1240ms 一次
|
* 喜欢文章,后端有限调用,1240ms 一次
|
||||||
*
|
*
|
||||||
* @param id 文章 ID
|
* @param id 文章 ID
|
||||||
* @returns 最新喜欢数量
|
* @returns 最新喜欢数量
|
||||||
*/
|
*/
|
||||||
async function like(id: string): Promise<number> {
|
async like(id: string): Promise<number> {
|
||||||
return axios.get(`/article/like/${id}`);
|
return axios.get(`/article/like/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default {
|
|
||||||
view,
|
|
||||||
like,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default ArticleAPI;
|
||||||
|
|||||||
+19
-17
@@ -1,9 +1,10 @@
|
|||||||
import {Attachment, BizUpdateReq, TempFileResp} from "../types";
|
import { Attachment, BizIdUpdate, TempFile } from "../types";
|
||||||
import { axios } from "./BaseAPI";
|
import { axios } from "./BaseAPI";
|
||||||
import CommonAPI from "./CommonAPI";
|
import CommonAPI from "./CommonAPI";
|
||||||
|
|
||||||
const BASE_URI = "/attach";
|
const BASE_URI = "/attach";
|
||||||
|
|
||||||
|
export const AttachmentAPI = {
|
||||||
/**
|
/**
|
||||||
* 上传临时附件
|
* 上传临时附件
|
||||||
*
|
*
|
||||||
@@ -11,7 +12,7 @@ const BASE_URI = "/attach";
|
|||||||
* @param ttl 有效期
|
* @param ttl 有效期
|
||||||
* @returns 临时附件列表
|
* @returns 临时附件列表
|
||||||
*/
|
*/
|
||||||
async function uploadTemp(file: File | File[], ttl?: string): Promise<TempFileResp[]> {
|
async upload(file: File | File[], ttl?: string): Promise<TempFile[]> {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
const fileList = Array.isArray(file) ? file : [file];
|
const fileList = Array.isArray(file) ? file : [file];
|
||||||
for (const item of fileList) {
|
for (const item of fileList) {
|
||||||
@@ -21,17 +22,15 @@ async function uploadTemp(file: File | File[], ttl?: string): Promise<TempFileRe
|
|||||||
formData.append("ttl", ttl);
|
formData.append("ttl", ttl);
|
||||||
}
|
}
|
||||||
return await axios.post(`${BASE_URI}/temp/upload`, formData);
|
return await axios.post(`${BASE_URI}/temp/upload`, formData);
|
||||||
}
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 按业务差分更新附件
|
* 按业务差分更新附件
|
||||||
*
|
*
|
||||||
* @param req 更新请求
|
* @param req 更新请求
|
||||||
*/
|
*/
|
||||||
async function updateByBiz(req: BizUpdateReq): Promise<void> {
|
async updateByBizId(req: BizIdUpdate): Promise<void> {
|
||||||
return await axios.post(`${BASE_URI}/update/biz`, req);
|
return await axios.post(`${BASE_URI}/update/biz/id`, req);
|
||||||
}
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 按业务查询附件列表
|
* 按业务查询附件列表
|
||||||
*
|
*
|
||||||
@@ -39,20 +38,23 @@ async function updateByBiz(req: BizUpdateReq): Promise<void> {
|
|||||||
* @param bizId 业务 ID
|
* @param bizId 业务 ID
|
||||||
* @param attachTypeList 附件类型列表
|
* @param attachTypeList 附件类型列表
|
||||||
*/
|
*/
|
||||||
async function listByBiz(bizType: string, bizId: string, attachTypeList?: string[]): Promise<Attachment[]> {
|
async listByBizId(bizType: string, bizId: string, attachTypeList?: string[]): Promise<Attachment[]> {
|
||||||
return await axios.get(`${BASE_URI}/list/biz`, {
|
return await axios.get(`${BASE_URI}/list/biz/id`, {
|
||||||
params: {
|
params: {
|
||||||
bizType,
|
bizType,
|
||||||
bizId,
|
bizId,
|
||||||
attachTypeList
|
attachTypeList
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
},
|
||||||
|
|
||||||
export default {
|
getReadURL(id: string): string {
|
||||||
uploadTemp,
|
return CommonAPI.getAttachmentReadAPI(id);
|
||||||
updateByBiz,
|
},
|
||||||
listByBiz,
|
|
||||||
getReadURL: CommonAPI.getAttachmentReadAPI,
|
getTempReadURL(id: string): string {
|
||||||
getTempReadURL: CommonAPI.getAttachmentTempReadAPI
|
return CommonAPI.getAttachmentTempReadAPI(id);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default AttachmentAPI;
|
||||||
|
|||||||
+7
-19
@@ -1,27 +1,15 @@
|
|||||||
import { CaptchaData, Comment, CommentReply, Page, PageResult } from "../types";
|
import { CaptchaData, Comment, Page, PageResult } from "../types";
|
||||||
import { axios } from "./BaseAPI";
|
import { axios } from "./BaseAPI";
|
||||||
|
|
||||||
const BASE_URI = "/comment";
|
const BASE_URI = "/comment";
|
||||||
|
|
||||||
async function page(page: Page<Comment>): Promise<PageResult<Comment>> {
|
export const CommentAPI = {
|
||||||
|
async page(page: Page<Comment>): Promise<PageResult<Comment>> {
|
||||||
return axios.post(`${BASE_URI}/list`, page);
|
return axios.post(`${BASE_URI}/list`, page);
|
||||||
}
|
},
|
||||||
|
async create(captchaData: CaptchaData<Comment>): Promise<void> {
|
||||||
async function create(captchaData: CaptchaData<Comment>): Promise<void> {
|
|
||||||
return axios.post(`${BASE_URI}/create`, captchaData);
|
return axios.post(`${BASE_URI}/create`, captchaData);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pageReply(page: Page<CommentReply>): Promise<PageResult<CommentReply>> {
|
|
||||||
return axios.post(`${BASE_URI}/reply/list`, page);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createReply(captchaData: CaptchaData<CommentReply>): Promise<void> {
|
|
||||||
return axios.post(`${BASE_URI}/reply/create`, captchaData);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default {
|
|
||||||
page,
|
|
||||||
create,
|
|
||||||
createReply,
|
|
||||||
pageReply
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default CommentAPI;
|
||||||
|
|||||||
+16
-23
@@ -1,4 +1,4 @@
|
|||||||
import {CaptchaResult, Setting, TemplateBizType} from "../types";
|
import { CaptchaResult, Setting } from "../types";
|
||||||
import { axios } from "./BaseAPI";
|
import { axios } from "./BaseAPI";
|
||||||
|
|
||||||
function getBaseURI(): string {
|
function getBaseURI(): string {
|
||||||
@@ -9,21 +9,20 @@ function getBaseURI(): string {
|
|||||||
return baseURL.replace(/\/$/, "");
|
return baseURL.replace(/\/$/, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
const getCaptchaAPI = () => `${getBaseURI()}/captcha`;
|
export const CommonAPI = {
|
||||||
|
getCaptchaAPI(): string {
|
||||||
async function captcha(width: number, height: number): Promise<CaptchaResult> {
|
return `${getBaseURI()}/captcha`;
|
||||||
|
},
|
||||||
|
async captcha(width: number, height: number): Promise<CaptchaResult> {
|
||||||
return await axios.get(`/captcha?width=${width}&height=${height}`);
|
return await axios.get(`/captcha?width=${width}&height=${height}`);
|
||||||
}
|
},
|
||||||
|
getAttachmentReadAPI(id: string): string {
|
||||||
const getAttachmentReadAPI = (id: string) => `${getBaseURI()}/attach/read/${id}`;
|
return `${getBaseURI()}/attach/read/${id}`;
|
||||||
|
},
|
||||||
const getAttachmentTempReadAPI = (id: string) => `${getBaseURI()}/attach/temp/read?id=${id}`;
|
getAttachmentTempReadAPI(id: string): string {
|
||||||
|
return `${getBaseURI()}/attach/temp/read?id=${id}`;
|
||||||
async function getTemplate(bizType: TemplateBizType, code: string): Promise<string> {
|
},
|
||||||
return axios.get(`/template?bizType=${bizType}&bizCode=${code}`);
|
async settingMap(map: Record<string, string[]>): Promise<Map<string, Map<string, Setting>>> {
|
||||||
}
|
|
||||||
|
|
||||||
async function settingMap(map: Record<string, string[]>): Promise<Map<string, Map<string, Setting>>> {
|
|
||||||
const raw = await axios.post("/setting/map", map);
|
const raw = await axios.post("/setting/map", map);
|
||||||
const moduleMap = new Map<string, Map<string, Setting>>();
|
const moduleMap = new Map<string, Map<string, Setting>>();
|
||||||
if (!raw || typeof raw !== "object") {
|
if (!raw || typeof raw !== "object") {
|
||||||
@@ -41,12 +40,6 @@ async function settingMap(map: Record<string, string[]>): Promise<Map<string, Ma
|
|||||||
}
|
}
|
||||||
return moduleMap;
|
return moduleMap;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default {
|
|
||||||
getCaptchaAPI,
|
|
||||||
captcha,
|
|
||||||
getAttachmentReadAPI,
|
|
||||||
getAttachmentTempReadAPI,
|
|
||||||
getTemplate,
|
|
||||||
settingMap
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default CommonAPI;
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ import { axios } from "./BaseAPI";
|
|||||||
|
|
||||||
const BASE_URI = "/git/developer";
|
const BASE_URI = "/git/developer";
|
||||||
|
|
||||||
async function get(): Promise<Developer> {
|
export const DeveloperAPI = {
|
||||||
|
async get(): Promise<Developer> {
|
||||||
return axios.post(`${BASE_URI}`);
|
return axios.post(`${BASE_URI}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default {
|
|
||||||
get
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default DeveloperAPI;
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { axios } from "./BaseAPI";
|
||||||
|
import type { CaptchaData, Notify, NotifyDetail, Page, PageResult } from "../types";
|
||||||
|
|
||||||
|
const BASE_URI = "/notify/internal";
|
||||||
|
|
||||||
|
export const NotifyAPI = {
|
||||||
|
|
||||||
|
async smsSend(captchaData: CaptchaData<Notify>) {
|
||||||
|
return await axios.post("/notify/sms/captcha/create", captchaData);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 查询当前用户的站内通知详情。 */
|
||||||
|
async internalDetail(id: string): Promise<NotifyDetail> {
|
||||||
|
return await axios.post(`${BASE_URI}/detail`, undefined, {
|
||||||
|
params: { id }
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 查询当前用户的站内通知。 */
|
||||||
|
async listInternal(page?: Page<NotifyDetail>, unreadOnly = false): Promise<PageResult<NotifyDetail>> {
|
||||||
|
return await axios.post(`${BASE_URI}/internal`, page, {
|
||||||
|
params: { unreadOnly }
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 查询当前用户未读站内通知数量。 */
|
||||||
|
async countUnreadInternal(): Promise<number> {
|
||||||
|
return await axios.post(`${BASE_URI}/unread/count`);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 标记当前用户的一条站内通知已读。 */
|
||||||
|
async markInternalRead(id: string): Promise<void> {
|
||||||
|
await axios.post(`${BASE_URI}/read`, undefined, {
|
||||||
|
params: { id }
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 删除当前用户的一条站内通知。 */
|
||||||
|
async deleteInternal(id: string): Promise<void> {
|
||||||
|
await axios.post(`${BASE_URI}/delete`, undefined, {
|
||||||
|
params: { id }
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 标记当前用户全部站内通知已读。 */
|
||||||
|
async markAllInternalRead(): Promise<void> {
|
||||||
|
await axios.post(`${BASE_URI}/read/all`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NotifyAPI;
|
||||||
+17
-17
@@ -1,29 +1,29 @@
|
|||||||
import { axios } from "./BaseAPI";
|
import { axios } from "./BaseAPI";
|
||||||
import type { Page, PageResult, Permission, PermissionPayload } from "../types";
|
import type { ModuleCode, Page, PageResult, Permission, PermissionPayload } from "../types";
|
||||||
|
|
||||||
const BASE_URI = "/user/permission";
|
const BASE_URI = "/user/permission";
|
||||||
|
|
||||||
async function list(req: Page<Permission>): Promise<PageResult<Permission>> {
|
export const PermissionAPI = {
|
||||||
|
async list(req: Page<Permission>): Promise<PageResult<Permission>> {
|
||||||
return axios.post(`${BASE_URI}/list`, req);
|
return axios.post(`${BASE_URI}/list`, req);
|
||||||
}
|
},
|
||||||
|
async create(req: PermissionPayload): Promise<void> {
|
||||||
async function create(req: PermissionPayload): Promise<void> {
|
|
||||||
return axios.post(`${BASE_URI}/create`, req);
|
return axios.post(`${BASE_URI}/create`, req);
|
||||||
}
|
},
|
||||||
|
async update(req: PermissionPayload): Promise<void> {
|
||||||
async function update(req: PermissionPayload): Promise<void> {
|
|
||||||
return axios.post(`${BASE_URI}/update`, req);
|
return axios.post(`${BASE_URI}/update`, req);
|
||||||
}
|
},
|
||||||
|
async remove(id: string): Promise<void> {
|
||||||
async function remove(id: string): Promise<void> {
|
|
||||||
return axios.post(`${BASE_URI}/delete`, undefined, {
|
return axios.post(`${BASE_URI}/delete`, undefined, {
|
||||||
params: { id }
|
params: { id }
|
||||||
});
|
});
|
||||||
|
},
|
||||||
|
/** 查询当前登录用户在指定模块内可转授的权限。 */
|
||||||
|
async grantableList(moduleCode: ModuleCode): Promise<Permission[]> {
|
||||||
|
return await axios.post(`${BASE_URI}/grantable/list`, undefined, {
|
||||||
|
params: { moduleCode }
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export default {
|
|
||||||
list,
|
|
||||||
create,
|
|
||||||
update,
|
|
||||||
remove
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default PermissionAPI;
|
||||||
|
|||||||
+69
-33
@@ -1,58 +1,94 @@
|
|||||||
import { axios } from "./BaseAPI";
|
import { axios } from "./BaseAPI";
|
||||||
import type { ModuleCode, Page, PageResult, Permission, Role, RolePayload, UserRoleAuthorizeReq } from "../types";
|
import type { ModuleCode, Page, PageResult, Permission, Role, RolePayload, AuthorizeUserRole } from "../types";
|
||||||
|
|
||||||
const BASE_URI = "/user/role";
|
const BASE_URI = "/user/role";
|
||||||
|
|
||||||
async function list(req: Page<Role>): Promise<PageResult<Role>> {
|
export const RoleAPI = {
|
||||||
|
async list(req: Page<Role>): Promise<PageResult<Role>> {
|
||||||
return axios.post(`${BASE_URI}/list`, req);
|
return axios.post(`${BASE_URI}/list`, req);
|
||||||
}
|
},
|
||||||
|
async detail(id: string): Promise<Role> {
|
||||||
async function detail(id: string): Promise<Role> {
|
|
||||||
return axios.post(`${BASE_URI}/detail`, undefined, {
|
return axios.post(`${BASE_URI}/detail`, undefined, {
|
||||||
params: { id }
|
params: { id }
|
||||||
});
|
});
|
||||||
}
|
},
|
||||||
|
async create(req: RolePayload): Promise<void> {
|
||||||
async function create(req: RolePayload): Promise<void> {
|
|
||||||
return axios.post(`${BASE_URI}/create`, req);
|
return axios.post(`${BASE_URI}/create`, req);
|
||||||
}
|
},
|
||||||
|
async update(req: RolePayload): Promise<void> {
|
||||||
async function update(req: RolePayload): Promise<void> {
|
|
||||||
return axios.post(`${BASE_URI}/update`, req);
|
return axios.post(`${BASE_URI}/update`, req);
|
||||||
}
|
},
|
||||||
|
async remove(id: string): Promise<void> {
|
||||||
async function remove(id: string): Promise<void> {
|
|
||||||
return axios.post(`${BASE_URI}/delete`, undefined, {
|
return axios.post(`${BASE_URI}/delete`, undefined, {
|
||||||
params: { id }
|
params: { id }
|
||||||
});
|
});
|
||||||
}
|
},
|
||||||
|
/** 查询当前登录用户在模块内可管理的角色。 */
|
||||||
async function authorizedList(userId: string, moduleCode?: ModuleCode): Promise<Role[]> {
|
async manageableList(moduleCode: ModuleCode): Promise<Role[]> {
|
||||||
|
return await axios.post(`${BASE_URI}/manageable/list`, undefined, {
|
||||||
|
params: { moduleCode }
|
||||||
|
});
|
||||||
|
},
|
||||||
|
/** 查询当前登录用户在模块内可授权给账号的角色。 */
|
||||||
|
async grantableList(moduleCode: ModuleCode): Promise<Role[]> {
|
||||||
|
return await axios.post(`${BASE_URI}/grantable/list`, undefined, {
|
||||||
|
params: { moduleCode }
|
||||||
|
});
|
||||||
|
},
|
||||||
|
/** 查询当前登录用户直属角色详情,不按可管理范围过滤。 */
|
||||||
|
async currentDetailList(moduleCode?: ModuleCode): Promise<Role[]> {
|
||||||
|
return await axios.post(`${BASE_URI}/current/detail/list`, undefined, {
|
||||||
|
params: { moduleCode }
|
||||||
|
});
|
||||||
|
},
|
||||||
|
/** 查询当前登录用户可以给目标角色配置的直接权限 P 候选项。 */
|
||||||
|
async grantablePermissionList(roleId: string): Promise<Permission[]> {
|
||||||
|
return await axios.post(`${BASE_URI}/permission/grantable/list`, undefined, {
|
||||||
|
params: { roleId }
|
||||||
|
});
|
||||||
|
},
|
||||||
|
/** 查询目标角色当前可继续转授的权限 D。 */
|
||||||
|
async delegationList(roleId: string): Promise<Permission[]> {
|
||||||
|
return await axios.post(`${BASE_URI}/delegation/list`, undefined, {
|
||||||
|
params: { roleId }
|
||||||
|
});
|
||||||
|
},
|
||||||
|
/** 查询当前登录用户可以给目标角色配置的 D 候选项。 */
|
||||||
|
async grantableDelegationList(roleId: string): Promise<Permission[]> {
|
||||||
|
return await axios.post(`${BASE_URI}/delegation/grantable/list`, undefined, {
|
||||||
|
params: { roleId }
|
||||||
|
});
|
||||||
|
},
|
||||||
|
/** 覆盖保存目标角色可继续转授的权限 D。 */
|
||||||
|
async updateDelegation(req: Pick<RolePayload, "id" | "delegationPermissionIdList">): Promise<void> {
|
||||||
|
await axios.post(`${BASE_URI}/delegation/update`, req);
|
||||||
|
},
|
||||||
|
/** 查询目标角色可选管理父级。 */
|
||||||
|
async parentGrantableList(roleId: string): Promise<Role[]> {
|
||||||
|
return await axios.post(`${BASE_URI}/parent/grantable/list`, undefined, {
|
||||||
|
params: { roleId }
|
||||||
|
});
|
||||||
|
},
|
||||||
|
/** 保存目标角色管理父级。 */
|
||||||
|
async updateParent(req: Pick<RolePayload, "id" | "parentRoleId">): Promise<void> {
|
||||||
|
await axios.post(`${BASE_URI}/parent/update`, req);
|
||||||
|
},
|
||||||
|
async authorizedList(userId: string, moduleCode?: ModuleCode): Promise<Role[]> {
|
||||||
return axios.post(`${BASE_URI}/authorized/list`, undefined, {
|
return axios.post(`${BASE_URI}/authorized/list`, undefined, {
|
||||||
params: {
|
params: {
|
||||||
userId,
|
userId,
|
||||||
moduleCode
|
moduleCode
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
},
|
||||||
|
async authorize(req: AuthorizeUserRole): Promise<void> {
|
||||||
async function authorize(req: UserRoleAuthorizeReq): Promise<void> {
|
|
||||||
return axios.post(`${BASE_URI}/authorized/create`, req);
|
return axios.post(`${BASE_URI}/authorized/create`, req);
|
||||||
}
|
},
|
||||||
|
async authorizedPermission(userId: string): Promise<Permission[]> {
|
||||||
async function authorizedPermission(userId: string): Promise<Permission[]> {
|
|
||||||
return axios.post(`${BASE_URI}/authorized/permission`, undefined, {
|
return axios.post(`${BASE_URI}/authorized/permission`, undefined, {
|
||||||
params: { userId }
|
params: { userId }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export default {
|
|
||||||
list,
|
|
||||||
detail,
|
|
||||||
create,
|
|
||||||
update,
|
|
||||||
remove,
|
|
||||||
authorizedList,
|
|
||||||
authorize,
|
|
||||||
authorizedPermission
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default RoleAPI;
|
||||||
|
|||||||
+65
-71
@@ -1,5 +1,4 @@
|
|||||||
import type {Attachment, CaptchaData, LoginRequest, LoginResponse, RegisterRequest, UpdatePasswordRequest, User} from "../types";
|
import type {Attachment, CaptchaData, Gender, LoginRequest, LoginResponse, RegisterRequest, SmsLoginRequest, UpdatePasswordRequest, User, UserAttachType} from "../types";
|
||||||
import {UserAttachType} from "../types";
|
|
||||||
import {axios} from "./BaseAPI";
|
import {axios} from "./BaseAPI";
|
||||||
import CommonAPI from "./CommonAPI";
|
import CommonAPI from "./CommonAPI";
|
||||||
|
|
||||||
@@ -7,65 +6,11 @@ const BASE_URI = "/user";
|
|||||||
type UserAttachmentOwner = {
|
type UserAttachmentOwner = {
|
||||||
attachmentList?: readonly Attachment[];
|
attachmentList?: readonly Attachment[];
|
||||||
};
|
};
|
||||||
|
type UpdateCurrentRequest = Omit<Partial<User>, "gender"> & {
|
||||||
|
gender?: Gender | "";
|
||||||
|
};
|
||||||
|
|
||||||
async function register(req: CaptchaData<RegisterRequest>): Promise<LoginResponse> {
|
function findAttachmentByType(attachmentList: readonly Attachment[], ...types: UserAttachType[]) {
|
||||||
return await axios.post(`${BASE_URI}/register`, req);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 登录
|
|
||||||
*
|
|
||||||
* @param req 验证码登录对象
|
|
||||||
* @returns LoginResponse
|
|
||||||
*/
|
|
||||||
async function login(req: CaptchaData<LoginRequest>): Promise<LoginResponse> {
|
|
||||||
return await axios.post(`${BASE_URI}/login`, req);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 验证是否已登录
|
|
||||||
*
|
|
||||||
* @returns true 为已登录
|
|
||||||
*/
|
|
||||||
async function loginByToken(): Promise<LoginResponse> {
|
|
||||||
return await axios.post(`${BASE_URI}/login/token`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function logout(): Promise<void> {
|
|
||||||
return await axios.post(`${BASE_URI}/logout`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function updateCurrent(req: Partial<User>): Promise<LoginResponse> {
|
|
||||||
return await axios.post(`${BASE_URI}/current/update`, req);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function updatePassword(req: UpdatePasswordRequest): Promise<void> {
|
|
||||||
return await axios.post(`${BASE_URI}/update/password`, req);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取用户数据
|
|
||||||
*
|
|
||||||
* @param id 用户 ID
|
|
||||||
* @returns 用户数据
|
|
||||||
*/
|
|
||||||
async function view(id: string): Promise<User> {
|
|
||||||
return await axios.post(`${BASE_URI}/view/${id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getAvatarURL(user?: UserAttachmentOwner) {
|
|
||||||
if (user?.attachmentList) {
|
|
||||||
return findAttachmentByType(user.attachmentList, [UserAttachType.AVATAR, UserAttachType.DEFAULT_AVATAR]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getWrapperURL(user?: UserAttachmentOwner) {
|
|
||||||
if (user?.attachmentList) {
|
|
||||||
return findAttachmentByType(user.attachmentList, [UserAttachType.WRAPPER, UserAttachType.DEFAULT_WRAPPER]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function findAttachmentByType(attachmentList: readonly Attachment[], types: UserAttachType[]) {
|
|
||||||
for (let i = 0; i < attachmentList.length; i++) {
|
for (let i = 0; i < attachmentList.length; i++) {
|
||||||
const attachType = attachmentList[i].attachType as UserAttachType | undefined;
|
const attachType = attachmentList[i].attachType as UserAttachType | undefined;
|
||||||
const id = attachmentList[i].id;
|
const id = attachmentList[i].id;
|
||||||
@@ -75,15 +20,64 @@ function findAttachmentByType(attachmentList: readonly Attachment[], types: User
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default {
|
export const UserAPI = {
|
||||||
register,
|
async register(req: CaptchaData<RegisterRequest>): Promise<LoginResponse> {
|
||||||
login,
|
return await axios.post(`${BASE_URI}/register`, req);
|
||||||
loginByToken,
|
},
|
||||||
logout,
|
/**
|
||||||
updateCurrent,
|
* 登录
|
||||||
updatePassword,
|
*
|
||||||
|
* @param req 验证码登录对象
|
||||||
view,
|
* @returns LoginResponse
|
||||||
getAvatarURL,
|
*/
|
||||||
getWrapperURL
|
async login(req: CaptchaData<LoginRequest>): Promise<LoginResponse> {
|
||||||
|
return await axios.post(`${BASE_URI}/login`, req);
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 短信验证码登录
|
||||||
|
*
|
||||||
|
* @param req 短信验证码登录请求
|
||||||
|
* @returns LoginResponse
|
||||||
|
*/
|
||||||
|
async loginBySms(req: SmsLoginRequest): Promise<LoginResponse> {
|
||||||
|
return await axios.post(`${BASE_URI}/login/sms`, req);
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 验证是否已登录
|
||||||
|
*
|
||||||
|
* @returns true 为已登录
|
||||||
|
*/
|
||||||
|
async loginByToken(): Promise<LoginResponse> {
|
||||||
|
return await axios.post(`${BASE_URI}/login/token`);
|
||||||
|
},
|
||||||
|
async logout(): Promise<void> {
|
||||||
|
return await axios.post(`${BASE_URI}/logout`);
|
||||||
|
},
|
||||||
|
async updateCurrent(req: UpdateCurrentRequest): Promise<LoginResponse> {
|
||||||
|
return await axios.post(`${BASE_URI}/current/update`, req);
|
||||||
|
},
|
||||||
|
async updatePassword(req: UpdatePasswordRequest): Promise<void> {
|
||||||
|
return await axios.post(`${BASE_URI}/update/password`, req);
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 获取用户数据
|
||||||
|
*
|
||||||
|
* @param id 用户 ID
|
||||||
|
* @returns 用户数据
|
||||||
|
*/
|
||||||
|
async view(id: string): Promise<User> {
|
||||||
|
return await axios.post(`${BASE_URI}/view/${id}`);
|
||||||
|
},
|
||||||
|
getAvatarURL(user?: UserAttachmentOwner) {
|
||||||
|
if (user?.attachmentList) {
|
||||||
|
return findAttachmentByType(user.attachmentList, "AVATAR");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getWrapperURL(user?: UserAttachmentOwner) {
|
||||||
|
if (user?.attachmentList) {
|
||||||
|
return findAttachmentByType(user.attachmentList, "WRAPPER");
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default UserAPI;
|
||||||
|
|||||||
+9
-8
@@ -1,8 +1,9 @@
|
|||||||
export { default as ArticleAPI } from "./ArticleAPI";
|
export { ArticleAPI } from "./ArticleAPI";
|
||||||
export { default as AttachmentAPI } from "./AttachmentAPI";
|
export { AttachmentAPI } from "./AttachmentAPI";
|
||||||
export { default as CommentAPI } from "./CommentAPI";
|
export { CommentAPI } from "./CommentAPI";
|
||||||
export { default as CommonAPI } from "./CommonAPI";
|
export { CommonAPI } from "./CommonAPI";
|
||||||
export { default as DeveloperAPI } from "./DeveloperAPI";
|
export { DeveloperAPI } from "./DeveloperAPI";
|
||||||
export { default as PermissionAPI } from "./PermissionAPI";
|
export { NotifyAPI } from "./NotifyAPI";
|
||||||
export { default as RoleAPI } from "./RoleAPI";
|
export { PermissionAPI } from "./PermissionAPI";
|
||||||
export { default as UserAPI } from "./UserAPI";
|
export { RoleAPI } from "./RoleAPI";
|
||||||
|
export { UserAPI } from "./UserAPI";
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
|
|
||||||
html {
|
html {
|
||||||
cursor: var(--tui-cur-default);
|
cursor: var(--tui-cur-default);
|
||||||
|
text-underline-offset: .16rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@@ -130,7 +131,7 @@ textarea {
|
|||||||
// 模糊玻璃效果:白色
|
// 模糊玻璃效果:白色
|
||||||
.glass-white {
|
.glass-white {
|
||||||
color: var(--eui-black, #000);
|
color: var(--eui-black, #000);
|
||||||
background: rgba(255, 255, 255, .8);
|
background: rgba(255, 255, 255, .6);
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
-webkit-backdrop-filter: blur(10px);
|
-webkit-backdrop-filter: blur(10px);
|
||||||
}
|
}
|
||||||
@@ -138,7 +139,7 @@ textarea {
|
|||||||
// 模糊玻璃效果:黑色
|
// 模糊玻璃效果:黑色
|
||||||
.glass-black {
|
.glass-black {
|
||||||
color: var(--eui-white, #FFF);
|
color: var(--eui-white, #FFF);
|
||||||
background: rgba(0, 0, 0, .8);
|
background: rgba(0, 0, 0, .6);
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
-webkit-backdrop-filter: blur(10px);
|
-webkit-backdrop-filter: blur(10px);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import view from "./index.vue";
|
||||||
|
import Toolkit from "../../utils/Toolkit";
|
||||||
|
import type { Article } from "../../types";
|
||||||
|
|
||||||
|
export * from "./shared";
|
||||||
|
export type ArticleEditorExpose = {
|
||||||
|
submit: () => Promise<Article>;
|
||||||
|
getPendingMediaCount: () => number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ArticleEditor = Toolkit.withInstall(view);
|
||||||
|
export default ArticleEditor;
|
||||||
|
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
<template>
|
||||||
|
<t-textarea
|
||||||
|
v-if="isTextContent"
|
||||||
|
:model-value="editorContent"
|
||||||
|
:autosize="textareaAutosize"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
@update:model-value="onSimpleEditorCommit"
|
||||||
|
/>
|
||||||
|
<markdown-editor
|
||||||
|
v-else-if="isMarkdownContent"
|
||||||
|
:data="editorContent"
|
||||||
|
@update:data="onSimpleEditorCommit"
|
||||||
|
/>
|
||||||
|
<tiptap-editor
|
||||||
|
v-else
|
||||||
|
ref="tiptapEditorRef"
|
||||||
|
:model-value="editorContent"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:upload-image="handleUploadImage"
|
||||||
|
:upload-video="handleUploadVideo"
|
||||||
|
@change="onEditorChange"
|
||||||
|
@update:model-value="onEditorCommit"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { Article, ArticleContentType, Attachment } from "../../types";
|
||||||
|
import AttachmentAPI from "../../api/AttachmentAPI";
|
||||||
|
import { MarkdownEditor } from "../markdown-editor";
|
||||||
|
import type { TiptapEditorExpose, UploadResult } from "../tiptap-editor";
|
||||||
|
import { TiptapEditor } from "../tiptap-editor";
|
||||||
|
import {
|
||||||
|
ATTACHMENT_ID_ATTR,
|
||||||
|
TEMP_FILE_ID_ATTR,
|
||||||
|
buildAttachmentListFromContent,
|
||||||
|
getAttachmentIdFromSrc,
|
||||||
|
getTempFileIdFromSrc,
|
||||||
|
normalizeArticleContentType,
|
||||||
|
normalizeContent,
|
||||||
|
normalizeContentForStore,
|
||||||
|
normalizeRawArticleContent,
|
||||||
|
sameAttachmentList
|
||||||
|
} from "./shared";
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: "ArticleEditor"
|
||||||
|
});
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
modelValue?: Article;
|
||||||
|
placeholder?: string;
|
||||||
|
uploadImage?: (file: File) => Promise<UploadResult>;
|
||||||
|
uploadVideo?: (file: File) => Promise<UploadResult>;
|
||||||
|
resolveAttachmentUrl?: (attachmentId: string, attachment?: Attachment) => string | Promise<string>;
|
||||||
|
resolveTempUrl?: (tempFileId: string) => string | Promise<string>;
|
||||||
|
}>(), {
|
||||||
|
modelValue: () => ({}),
|
||||||
|
placeholder: "请输入文章正文内容"
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
"change": [value: Article];
|
||||||
|
"update:modelValue": [value: Article];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const textareaAutosize = {
|
||||||
|
minRows: 8,
|
||||||
|
maxRows: 32
|
||||||
|
};
|
||||||
|
|
||||||
|
const editorContent = ref("");
|
||||||
|
const tiptapEditorRef = ref<TiptapEditorExpose>();
|
||||||
|
const lastOutgoingContent = ref("");
|
||||||
|
const lastOutgoingAttachmentList = ref<Attachment[]>([]);
|
||||||
|
|
||||||
|
const contentType = computed(() => {
|
||||||
|
return normalizeArticleContentType(props.modelValue?.contentType);
|
||||||
|
});
|
||||||
|
const isTextContent = computed(() => contentType.value === "TEXT");
|
||||||
|
const isMarkdownContent = computed(() => contentType.value === "MARKDOWN");
|
||||||
|
|
||||||
|
const attachmentMap = computed(() => {
|
||||||
|
return new Map((props.modelValue?.attachmentList || []).map(item => [item.id, item]));
|
||||||
|
});
|
||||||
|
|
||||||
|
async function defaultUploadImage(file: File) {
|
||||||
|
return defaultUploadMedia(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function defaultUploadVideo(file: File) {
|
||||||
|
return defaultUploadMedia(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function defaultUploadMedia(file: File) {
|
||||||
|
const tempFileList = await AttachmentAPI.upload([file]);
|
||||||
|
const tempFileId = tempFileList[0]?.id || "";
|
||||||
|
if (!tempFileId) {
|
||||||
|
throw new Error("上传临时文件失败");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: tempFileId,
|
||||||
|
url: AttachmentAPI.getTempReadURL(tempFileId),
|
||||||
|
name: file.name
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUploadImage(file: File) {
|
||||||
|
if (props.uploadImage) {
|
||||||
|
return props.uploadImage(file);
|
||||||
|
}
|
||||||
|
return defaultUploadImage(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUploadVideo(file: File) {
|
||||||
|
if (props.uploadVideo) {
|
||||||
|
return props.uploadVideo(file);
|
||||||
|
}
|
||||||
|
return defaultUploadVideo(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveAttachmentPreviewUrl(attachmentId: string) {
|
||||||
|
const attachment = attachmentMap.value.get(attachmentId);
|
||||||
|
if (props.resolveAttachmentUrl) {
|
||||||
|
const customUrl = await props.resolveAttachmentUrl(attachmentId, attachment);
|
||||||
|
if (customUrl) {
|
||||||
|
return customUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const previewUrl = `${attachment?.metadata?.previewUrl || ""}`;
|
||||||
|
if (previewUrl) {
|
||||||
|
return previewUrl;
|
||||||
|
}
|
||||||
|
return attachmentId ? AttachmentAPI.getReadURL(attachmentId) : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveTempPreviewUrl(tempFileId: string) {
|
||||||
|
if (props.resolveTempUrl) {
|
||||||
|
return props.resolveTempUrl(tempFileId);
|
||||||
|
}
|
||||||
|
return AttachmentAPI.getTempReadURL(tempFileId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function convertContentForEditor(value?: string) {
|
||||||
|
if (contentType.value !== "TIP_TAP") {
|
||||||
|
return normalizeRawArticleContent(value);
|
||||||
|
}
|
||||||
|
const rawContent = normalizeContent(value);
|
||||||
|
if (!rawContent) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const doc = parser.parseFromString(rawContent, "text/html");
|
||||||
|
const mediaList = Array.from(doc.querySelectorAll("img, video"));
|
||||||
|
for (const media of mediaList) {
|
||||||
|
const src = media.getAttribute("src") || "";
|
||||||
|
const attachmentId = media.getAttribute(ATTACHMENT_ID_ATTR) || getAttachmentIdFromSrc(src);
|
||||||
|
const tempFileId = media.getAttribute(TEMP_FILE_ID_ATTR) || getTempFileIdFromSrc(src);
|
||||||
|
if (tempFileId) {
|
||||||
|
media.setAttribute(TEMP_FILE_ID_ATTR, tempFileId);
|
||||||
|
media.setAttribute(ATTACHMENT_ID_ATTR, attachmentId || tempFileId);
|
||||||
|
media.setAttribute("src", await resolveTempPreviewUrl(tempFileId));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!attachmentId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
media.setAttribute(ATTACHMENT_ID_ATTR, attachmentId);
|
||||||
|
const previewUrl = await resolveAttachmentPreviewUrl(attachmentId);
|
||||||
|
if (previewUrl) {
|
||||||
|
media.setAttribute("src", previewUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return normalizeContent(doc.body.innerHTML);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeIncomingArticleContent(content: string | undefined, currentContentType: ArticleContentType) {
|
||||||
|
if (currentContentType === "TIP_TAP") {
|
||||||
|
return normalizeContent(content);
|
||||||
|
}
|
||||||
|
return normalizeRawArticleContent(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildStoredContent(content: string, currentContentType: ArticleContentType) {
|
||||||
|
if (currentContentType === "TIP_TAP") {
|
||||||
|
return normalizeContentForStore(content);
|
||||||
|
}
|
||||||
|
return normalizeRawArticleContent(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildArticleAttachmentList(content: string, currentContentType: ArticleContentType) {
|
||||||
|
if (currentContentType === "TIP_TAP") {
|
||||||
|
return buildAttachmentListFromContent(content);
|
||||||
|
}
|
||||||
|
return props.modelValue?.attachmentList || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildArticleValue(content: string) {
|
||||||
|
const nextContentType = contentType.value;
|
||||||
|
return {
|
||||||
|
...(props.modelValue || {}),
|
||||||
|
contentType: nextContentType,
|
||||||
|
content: buildStoredContent(content, nextContentType),
|
||||||
|
attachmentList: buildArticleAttachmentList(content, nextContentType)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncLastOutgoingArticle(value: Article) {
|
||||||
|
const nextContentType = normalizeArticleContentType(value.contentType);
|
||||||
|
lastOutgoingContent.value = normalizeIncomingArticleContent(value.content, nextContentType);
|
||||||
|
lastOutgoingAttachmentList.value = value.attachmentList || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 实时变更只给预览态使用,不能反向驱动编辑器提交态
|
||||||
|
function onEditorChange(value: string) {
|
||||||
|
const nextValue = buildArticleValue(value);
|
||||||
|
syncLastOutgoingArticle(nextValue);
|
||||||
|
emit("change", {
|
||||||
|
...nextValue
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提交态只在离开代码块后的同步时机更新
|
||||||
|
function onEditorCommit(value: string) {
|
||||||
|
editorContent.value = value;
|
||||||
|
const nextValue = buildArticleValue(value);
|
||||||
|
syncLastOutgoingArticle(nextValue);
|
||||||
|
emit("update:modelValue", {
|
||||||
|
...nextValue
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSimpleEditorCommit(value: string) {
|
||||||
|
editorContent.value = value;
|
||||||
|
const nextValue = buildArticleValue(value);
|
||||||
|
syncLastOutgoingArticle(nextValue);
|
||||||
|
emit("change", {
|
||||||
|
...nextValue
|
||||||
|
});
|
||||||
|
emit("update:modelValue", {
|
||||||
|
...nextValue
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => [props.modelValue?.content, contentType.value] as const, async ([value, nextContentType]) => {
|
||||||
|
const nextValue = normalizeIncomingArticleContent(value, nextContentType);
|
||||||
|
if (nextValue === lastOutgoingContent.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (buildStoredContent(editorContent.value, nextContentType) === nextValue) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextContent = await convertContentForEditor(nextValue);
|
||||||
|
if (nextContent === editorContent.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
editorContent.value = nextContent;
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
watch(() => props.modelValue?.attachmentList, async (value) => {
|
||||||
|
if (contentType.value !== "TIP_TAP") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (sameAttachmentList(lastOutgoingAttachmentList.value, value)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (sameAttachmentList(buildAttachmentListFromContent(editorContent.value), value)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextContent = await convertContentForEditor(props.modelValue?.content);
|
||||||
|
if (nextContent === editorContent.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
editorContent.value = nextContent;
|
||||||
|
}, { deep: true });
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
const nextEditorContent = contentType.value === "TIP_TAP" && tiptapEditorRef.value ? await tiptapEditorRef.value.submit() : editorContent.value;
|
||||||
|
const nextValue = buildArticleValue(nextEditorContent);
|
||||||
|
editorContent.value = nextEditorContent;
|
||||||
|
syncLastOutgoingArticle(nextValue);
|
||||||
|
emit("update:modelValue", nextValue);
|
||||||
|
return nextValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
submit,
|
||||||
|
getPendingMediaCount: () => tiptapEditorRef.value?.getPendingMediaCount() || 0
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import type { ArticleContentType, Attachment } from "../../types";
|
||||||
|
import { ATTACHMENT_ID_ATTR, TEMP_FILE_ID_ATTR, isLocalMediaId, normalizeContent } from "../tiptap-editor/shared";
|
||||||
|
|
||||||
|
export const ATTACHMENT_ID_PREFIX = "attachment-id:";
|
||||||
|
export const TEMP_FILE_ID_PREFIX = "temp-file-id:";
|
||||||
|
export const ARTICLE_IMAGE_ATTACH_TYPE = "CONTENT_IMAGE";
|
||||||
|
export const ARTICLE_VIDEO_ATTACH_TYPE = "CONTENT_VIDEO";
|
||||||
|
export const DEFAULT_ARTICLE_CONTENT_TYPE: ArticleContentType = "TIP_TAP";
|
||||||
|
export const ARTICLE_CONTENT_TYPE_LIST: ArticleContentType[] = ["TEXT", "MARKDOWN", "TIP_TAP"];
|
||||||
|
|
||||||
|
export { ATTACHMENT_ID_ATTR, TEMP_FILE_ID_ATTR, normalizeContent };
|
||||||
|
|
||||||
|
export function normalizeArticleContentType(contentType?: string): ArticleContentType {
|
||||||
|
if (contentType === "TIPTAP") {
|
||||||
|
return "TIP_TAP";
|
||||||
|
}
|
||||||
|
return ARTICLE_CONTENT_TYPE_LIST.includes(contentType as ArticleContentType) ? contentType as ArticleContentType : DEFAULT_ARTICLE_CONTENT_TYPE;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeRawArticleContent(content?: string) {
|
||||||
|
return content || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAttachmentIdFromSrc(src?: string) {
|
||||||
|
if (!src?.startsWith(ATTACHMENT_ID_PREFIX)) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return src.slice(ATTACHMENT_ID_PREFIX.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTempFileIdFromSrc(src?: string) {
|
||||||
|
if (!src?.startsWith(TEMP_FILE_ID_PREFIX)) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return src.slice(TEMP_FILE_ID_PREFIX.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAttachmentIdSrc(attachmentId: string) {
|
||||||
|
return `${ATTACHMENT_ID_PREFIX}${attachmentId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMediaAttachType(tagName: string) {
|
||||||
|
return tagName.toLowerCase() === "video" ? ARTICLE_VIDEO_ATTACH_TYPE : ARTICLE_IMAGE_ATTACH_TYPE;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAttachmentMetadata(src: string) {
|
||||||
|
if (!src.startsWith("blob:")) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
previewUrl: src
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAttachmentTempFileId(tempFileId?: string) {
|
||||||
|
if (!tempFileId || isLocalMediaId(tempFileId)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return tempFileId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAttachmentListFromContent(content?: string) {
|
||||||
|
const rawContent = normalizeContent(content);
|
||||||
|
if (!rawContent) {
|
||||||
|
return [] as Attachment[];
|
||||||
|
}
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const doc = parser.parseFromString(rawContent, "text/html");
|
||||||
|
const mediaList = Array.from(doc.querySelectorAll("img, video"));
|
||||||
|
const attachmentMap = new Map<string, Attachment>();
|
||||||
|
|
||||||
|
for (const media of mediaList) {
|
||||||
|
const src = media.getAttribute("src") || "";
|
||||||
|
const attachmentId = media.getAttribute(ATTACHMENT_ID_ATTR) || getAttachmentIdFromSrc(src);
|
||||||
|
const tempFileId = media.getAttribute(TEMP_FILE_ID_ATTR) || getTempFileIdFromSrc(src);
|
||||||
|
const key = attachmentId || tempFileId;
|
||||||
|
if (!key || attachmentMap.has(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
attachmentMap.set(key, {
|
||||||
|
id: attachmentId || key,
|
||||||
|
tempFileId: normalizeAttachmentTempFileId(tempFileId),
|
||||||
|
name: media.getAttribute("title") || media.getAttribute("alt") || "",
|
||||||
|
attachType: getMediaAttachType(media.tagName),
|
||||||
|
metadata: buildAttachmentMetadata(src)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(attachmentMap.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeContentForStore(content?: string) {
|
||||||
|
const rawContent = normalizeContent(content);
|
||||||
|
if (!rawContent) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const doc = parser.parseFromString(rawContent, "text/html");
|
||||||
|
const mediaList = Array.from(doc.querySelectorAll("img, video"));
|
||||||
|
for (const media of mediaList) {
|
||||||
|
const src = media.getAttribute("src") || "";
|
||||||
|
const attachmentId = media.getAttribute(ATTACHMENT_ID_ATTR) || getAttachmentIdFromSrc(src);
|
||||||
|
const tempFileId = media.getAttribute(TEMP_FILE_ID_ATTR) || getTempFileIdFromSrc(src);
|
||||||
|
const finalAttachmentId = attachmentId || tempFileId;
|
||||||
|
if (!finalAttachmentId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
media.setAttribute(ATTACHMENT_ID_ATTR, finalAttachmentId);
|
||||||
|
media.removeAttribute(TEMP_FILE_ID_ATTR);
|
||||||
|
media.setAttribute("src", buildAttachmentIdSrc(finalAttachmentId));
|
||||||
|
}
|
||||||
|
return normalizeContent(doc.body.innerHTML);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sameAttachmentList(left?: Attachment[], right?: Attachment[]) {
|
||||||
|
const leftList = left || [];
|
||||||
|
const rightList = right || [];
|
||||||
|
if (leftList.length !== rightList.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (let i = 0; i < leftList.length; i += 1) {
|
||||||
|
const leftItem = leftList[i];
|
||||||
|
const rightItem = rightList[i];
|
||||||
|
if (!rightItem) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (leftItem.id !== rightItem.id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if ((leftItem.tempFileId || "") !== (rightItem.tempFileId || "")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if ((leftItem.accessKey?.value || "") !== (rightItem.accessKey?.value || "")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import view from "./index.vue";
|
||||||
|
import Toolkit from "../../utils/Toolkit";
|
||||||
|
|
||||||
|
export const ArticleView = Toolkit.withInstall(view);
|
||||||
|
export default ArticleView;
|
||||||
|
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
<template>
|
||||||
|
<t-textarea
|
||||||
|
v-if="isTextContent && hasRawContent"
|
||||||
|
class="text-view"
|
||||||
|
:model-value="rawContent"
|
||||||
|
:autosize="textareaAutosize"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
<t-empty v-else-if="isTextContent" :description="emptyText" />
|
||||||
|
<markdown-view v-else-if="isMarkdownContent && hasRawContent" :content="rawContent" />
|
||||||
|
<t-empty v-else-if="isMarkdownContent" :description="emptyText" />
|
||||||
|
<tiptap-view v-else :content="renderContent" :empty-text="emptyText" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { Article, Attachment } from "../../types";
|
||||||
|
import AttachmentAPI from "../../api/AttachmentAPI";
|
||||||
|
import { MarkdownView } from "../markdown-view";
|
||||||
|
import { TiptapView } from "../tiptap-view";
|
||||||
|
import {
|
||||||
|
ATTACHMENT_ID_ATTR,
|
||||||
|
getAttachmentIdFromSrc,
|
||||||
|
normalizeArticleContentType,
|
||||||
|
normalizeContent,
|
||||||
|
normalizeRawArticleContent
|
||||||
|
} from "../article-editor/shared";
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: "ArticleView"
|
||||||
|
});
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
article?: Article;
|
||||||
|
emptyText?: string;
|
||||||
|
resolveAttachmentUrl?: (attachmentId: string, attachment?: Attachment) => string | Promise<string>;
|
||||||
|
}>(), {
|
||||||
|
article: () => ({}),
|
||||||
|
emptyText: "暂无正文内容"
|
||||||
|
});
|
||||||
|
|
||||||
|
const textareaAutosize = {
|
||||||
|
minRows: 8,
|
||||||
|
maxRows: 32
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderContent = ref("");
|
||||||
|
|
||||||
|
const contentType = computed(() => {
|
||||||
|
return normalizeArticleContentType(props.article?.contentType);
|
||||||
|
});
|
||||||
|
const isTextContent = computed(() => contentType.value === "TEXT");
|
||||||
|
const isMarkdownContent = computed(() => contentType.value === "MARKDOWN");
|
||||||
|
const rawContent = computed(() => {
|
||||||
|
return normalizeRawArticleContent(props.article?.content);
|
||||||
|
});
|
||||||
|
const hasRawContent = computed(() => {
|
||||||
|
return 0 < rawContent.value.trim().length;
|
||||||
|
});
|
||||||
|
|
||||||
|
const attachmentMap = computed(() => {
|
||||||
|
return new Map((props.article?.attachmentList || []).map(item => [item.id, item]));
|
||||||
|
});
|
||||||
|
|
||||||
|
async function buildAttachmentPreviewUrl(attachmentId: string) {
|
||||||
|
const attachment = attachmentMap.value.get(attachmentId);
|
||||||
|
if (props.resolveAttachmentUrl) {
|
||||||
|
const customUrl = await props.resolveAttachmentUrl(attachmentId, attachment);
|
||||||
|
if (customUrl) {
|
||||||
|
return customUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const previewUrl = `${attachment?.metadata?.previewUrl || ""}`;
|
||||||
|
if (previewUrl) {
|
||||||
|
return previewUrl;
|
||||||
|
}
|
||||||
|
return attachmentId ? AttachmentAPI.getReadURL(attachmentId) : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildRenderContent(content?: string) {
|
||||||
|
const rawContent = normalizeContent(content);
|
||||||
|
if (!rawContent) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const doc = parser.parseFromString(rawContent, "text/html");
|
||||||
|
const nodes = Array.from(doc.querySelectorAll("img, video, source"));
|
||||||
|
for (const node of nodes) {
|
||||||
|
const src = node.getAttribute("src") || "";
|
||||||
|
const attachmentId = node.getAttribute(ATTACHMENT_ID_ATTR) || getAttachmentIdFromSrc(src);
|
||||||
|
if (!attachmentId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const previewUrl = await buildAttachmentPreviewUrl(attachmentId);
|
||||||
|
if (previewUrl) {
|
||||||
|
node.setAttribute("src", previewUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return normalizeContent(doc.body.innerHTML);
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.article?.content, props.article?.attachmentList, contentType.value] as const,
|
||||||
|
async ([, , nextContentType]) => {
|
||||||
|
if (nextContentType !== "TIP_TAP") {
|
||||||
|
renderContent.value = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renderContent.value = await buildRenderContent(props.article?.content);
|
||||||
|
},
|
||||||
|
{ immediate: true, deep: true }
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.text-view {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import view from "./index.vue";
|
||||||
|
import Toolkit from "../../utils/Toolkit";
|
||||||
|
import "./style.less";
|
||||||
|
|
||||||
|
export type CodeEditorScrollState = {
|
||||||
|
top: number;
|
||||||
|
left: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CodeEditorExpose = {
|
||||||
|
focus: () => void;
|
||||||
|
blur: () => void;
|
||||||
|
getScrollState: () => CodeEditorScrollState;
|
||||||
|
setScrollState: (state: Partial<CodeEditorScrollState>) => void;
|
||||||
|
findNext: () => boolean;
|
||||||
|
findPrevious: () => boolean;
|
||||||
|
replaceCurrent: () => boolean;
|
||||||
|
replaceAll: () => number;
|
||||||
|
openSearch: (showReplace: boolean) => void;
|
||||||
|
closeSearch: () => void;
|
||||||
|
textareaRef?: HTMLTextAreaElement;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CodeEditor = Toolkit.withInstall(view);
|
||||||
|
export default CodeEditor;
|
||||||
|
|
||||||
@@ -0,0 +1,450 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
ref="rootRef"
|
||||||
|
class="tui-code-editor"
|
||||||
|
:class="{
|
||||||
|
readonly,
|
||||||
|
disabled,
|
||||||
|
'with-line-numbers': lineNumbers
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div v-if="lineNumbers" ref="gutterRef" class="gutter diselect" aria-hidden="true">
|
||||||
|
<span v-for="line in lineCount" :key="line" v-text="line" />
|
||||||
|
</div>
|
||||||
|
<div class="body">
|
||||||
|
<editor-search-bar
|
||||||
|
ref="searchBarRef"
|
||||||
|
:visible="searchVisible"
|
||||||
|
:expanded="replaceExpanded"
|
||||||
|
:keyword="searchKeyword"
|
||||||
|
:replace-value="replaceKeyword"
|
||||||
|
:status-error="searchError"
|
||||||
|
:match-index="searchMatchIndex"
|
||||||
|
:match-count="searchMatchList.length"
|
||||||
|
:case-sensitive="searchOptions.caseSensitive"
|
||||||
|
:use-regex="searchOptions.useRegex"
|
||||||
|
@update:expanded="replaceExpanded = $event"
|
||||||
|
@update:keyword="searchKeyword = $event"
|
||||||
|
@update:replace-value="replaceKeyword = $event"
|
||||||
|
@update:case-sensitive="searchOptions.caseSensitive = $event"
|
||||||
|
@update:use-regex="searchOptions.useRegex = $event"
|
||||||
|
@previous="findPrevious"
|
||||||
|
@next="onSearchNext"
|
||||||
|
@replace="replaceCurrent"
|
||||||
|
@replace-all="replaceAll"
|
||||||
|
@close="closeSearch"
|
||||||
|
/>
|
||||||
|
<pre
|
||||||
|
ref="preRef"
|
||||||
|
class="preview cur-text"
|
||||||
|
:class="languageClass"
|
||||||
|
@scroll="onPreviewScroll"
|
||||||
|
><code :class="languageClass" v-html="highlightedHTML"></code></pre>
|
||||||
|
<textarea
|
||||||
|
v-if="!readonly"
|
||||||
|
ref="textareaRef"
|
||||||
|
v-model="currentValue"
|
||||||
|
class="input"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:disabled="disabled"
|
||||||
|
wrap="off"
|
||||||
|
:spellcheck="false"
|
||||||
|
@input="onInput"
|
||||||
|
@scroll="onScroll"
|
||||||
|
@keydown="onKeydown"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import Prism from "prismjs";
|
||||||
|
import EditorSearchBar from "../editor-search/index.vue";
|
||||||
|
import {
|
||||||
|
findTextMatches,
|
||||||
|
replaceAllText,
|
||||||
|
replaceMatchText,
|
||||||
|
type TextSearchMatch,
|
||||||
|
type TextSearchOptions
|
||||||
|
} from "../editor-search/search";
|
||||||
|
import { resolveCodeEditorKeydown } from "./input";
|
||||||
|
import { resolvePrismLanguage } from "../../utils/Prism";
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: "CodeEditor"
|
||||||
|
});
|
||||||
|
|
||||||
|
type ScrollState = {
|
||||||
|
top: number;
|
||||||
|
left: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type EditorSearchBarExpose = {
|
||||||
|
focusKeyword: (selectAll?: boolean) => void;
|
||||||
|
focusReplace: (selectAll?: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
modelValue?: string;
|
||||||
|
value?: string;
|
||||||
|
language?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
readonly?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
lineNumbers?: boolean;
|
||||||
|
}>(), {
|
||||||
|
modelValue: "",
|
||||||
|
language: "typescript",
|
||||||
|
placeholder: "",
|
||||||
|
readonly: false,
|
||||||
|
disabled: false,
|
||||||
|
lineNumbers: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: "update:modelValue", value: string): void;
|
||||||
|
(event: "change", value: string): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const bindValue = computed(() => props.value ?? props.modelValue);
|
||||||
|
const currentValue = ref(bindValue.value);
|
||||||
|
const rootRef = ref<HTMLDivElement>();
|
||||||
|
const gutterRef = ref<HTMLDivElement>();
|
||||||
|
const preRef = ref<HTMLPreElement>();
|
||||||
|
const textareaRef = ref<HTMLTextAreaElement>();
|
||||||
|
const searchBarRef = ref<EditorSearchBarExpose>();
|
||||||
|
const searchVisible = ref(false);
|
||||||
|
const replaceExpanded = ref(false);
|
||||||
|
const searchKeyword = ref("");
|
||||||
|
const replaceKeyword = ref("");
|
||||||
|
const searchError = ref("");
|
||||||
|
const searchMatchIndex = ref(-1);
|
||||||
|
const searchMatchList = ref<TextSearchMatch[]>([]);
|
||||||
|
const scrollState = reactive<ScrollState>({
|
||||||
|
top: 0,
|
||||||
|
left: 0
|
||||||
|
});
|
||||||
|
const searchOptions = reactive<TextSearchOptions>({
|
||||||
|
caseSensitive: false,
|
||||||
|
useRegex: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolvedLanguage = computed(() => resolvePrismLanguage(props.language));
|
||||||
|
const languageClass = computed(() => resolvedLanguage.value ? `language-${resolvedLanguage.value}` : "");
|
||||||
|
const lineCount = computed(() => Math.max(currentValue.value.split("\n").length, 1));
|
||||||
|
const highlightedHTML = computed(() => {
|
||||||
|
const value = currentValue.value || " ";
|
||||||
|
if (!resolvedLanguage.value) {
|
||||||
|
return escapeHTML(value);
|
||||||
|
}
|
||||||
|
return Prism.highlight(value, Prism.languages[resolvedLanguage.value], resolvedLanguage.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(bindValue, (value) => {
|
||||||
|
if (value === currentValue.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
currentValue.value = value;
|
||||||
|
refreshSearchMatchState();
|
||||||
|
nextTick(syncScrollPosition);
|
||||||
|
});
|
||||||
|
|
||||||
|
watch([searchKeyword, () => searchOptions.caseSensitive, () => searchOptions.useRegex], () => {
|
||||||
|
refreshSearchMatchState();
|
||||||
|
});
|
||||||
|
|
||||||
|
const onInput = () => {
|
||||||
|
emit("update:modelValue", currentValue.value);
|
||||||
|
emit("change", currentValue.value);
|
||||||
|
refreshSearchMatchState();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onScroll = () => {
|
||||||
|
syncScrollPosition();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPreviewScroll = () => {
|
||||||
|
if (!props.readonly || !preRef.value || !gutterRef.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
scrollState.top = preRef.value.scrollTop;
|
||||||
|
scrollState.left = preRef.value.scrollLeft;
|
||||||
|
gutterRef.value.scrollTop = preRef.value.scrollTop;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onKeydown = (event: KeyboardEvent) => {
|
||||||
|
if ((event.ctrlKey || event.metaKey) && !event.altKey) {
|
||||||
|
const key = event.key.toLowerCase();
|
||||||
|
if ("f" === key) {
|
||||||
|
event.preventDefault();
|
||||||
|
openSearch(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ("h" === key && !props.readonly) {
|
||||||
|
event.preventDefault();
|
||||||
|
openSearch(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (props.readonly || props.disabled || !textareaRef.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const textarea = textareaRef.value;
|
||||||
|
const nextState = resolveCodeEditorKeydown(currentValue.value, {
|
||||||
|
start: textarea.selectionStart,
|
||||||
|
end: textarea.selectionEnd
|
||||||
|
}, event);
|
||||||
|
if (!nextState) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
currentValue.value = nextState.value;
|
||||||
|
emit("update:modelValue", currentValue.value);
|
||||||
|
emit("change", currentValue.value);
|
||||||
|
refreshSearchMatchState();
|
||||||
|
nextTick(() => {
|
||||||
|
textarea.selectionStart = nextState.start;
|
||||||
|
textarea.selectionEnd = nextState.end;
|
||||||
|
syncScrollPosition();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getScrollState = (): ScrollState => ({
|
||||||
|
top: scrollState.top,
|
||||||
|
left: scrollState.left
|
||||||
|
});
|
||||||
|
|
||||||
|
const setScrollState = (nextState: Partial<ScrollState>) => {
|
||||||
|
scrollState.top = Math.max(0, nextState.top || 0);
|
||||||
|
scrollState.left = Math.max(0, nextState.left || 0);
|
||||||
|
applyScrollState();
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncScrollPosition = () => {
|
||||||
|
if (textareaRef.value) {
|
||||||
|
scrollState.top = textareaRef.value.scrollTop;
|
||||||
|
scrollState.left = textareaRef.value.scrollLeft;
|
||||||
|
applyScrollState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (props.readonly && preRef.value) {
|
||||||
|
scrollState.top = preRef.value.scrollTop;
|
||||||
|
scrollState.left = preRef.value.scrollLeft;
|
||||||
|
applyScrollState();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyScrollState = () => {
|
||||||
|
if (textareaRef.value) {
|
||||||
|
textareaRef.value.scrollTop = scrollState.top;
|
||||||
|
textareaRef.value.scrollLeft = scrollState.left;
|
||||||
|
}
|
||||||
|
if (preRef.value) {
|
||||||
|
preRef.value.scrollTop = scrollState.top;
|
||||||
|
preRef.value.scrollLeft = scrollState.left;
|
||||||
|
}
|
||||||
|
if (gutterRef.value) {
|
||||||
|
gutterRef.value.scrollTop = scrollState.top;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSelectionKeyword = () => {
|
||||||
|
if (!textareaRef.value) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const { selectionStart, selectionEnd } = textareaRef.value;
|
||||||
|
if (selectionStart === selectionEnd) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return currentValue.value.slice(selectionStart, selectionEnd);
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshSearchMatchState = () => {
|
||||||
|
const result = findTextMatches(currentValue.value, searchKeyword.value, searchOptions);
|
||||||
|
searchError.value = result.error;
|
||||||
|
searchMatchList.value = result.matches;
|
||||||
|
if (searchError.value || !textareaRef.value || 1 > searchMatchList.value.length) {
|
||||||
|
searchMatchIndex.value = -1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { selectionStart, selectionEnd } = textareaRef.value;
|
||||||
|
searchMatchIndex.value = searchMatchList.value.findIndex((match) => (
|
||||||
|
match.start === selectionStart && match.end === selectionEnd
|
||||||
|
));
|
||||||
|
if (0 > searchMatchIndex.value) {
|
||||||
|
searchMatchIndex.value = 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openSearch = (showReplace: boolean) => {
|
||||||
|
searchVisible.value = true;
|
||||||
|
replaceExpanded.value = showReplace;
|
||||||
|
const selectedText = getSelectionKeyword();
|
||||||
|
if (selectedText) {
|
||||||
|
searchKeyword.value = selectedText;
|
||||||
|
}
|
||||||
|
refreshSearchMatchState();
|
||||||
|
nextTick(() => {
|
||||||
|
searchBarRef.value?.focusKeyword(true);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeSearch = () => {
|
||||||
|
searchVisible.value = false;
|
||||||
|
replaceExpanded.value = false;
|
||||||
|
focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectMatch = (match: TextSearchMatch, matchIndex: number) => {
|
||||||
|
if (!textareaRef.value) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
textareaRef.value.focus();
|
||||||
|
textareaRef.value.selectionStart = match.start;
|
||||||
|
textareaRef.value.selectionEnd = match.end;
|
||||||
|
textareaRef.value.setSelectionRange(match.start, match.end);
|
||||||
|
textareaRef.value.scrollIntoView({ block: "nearest", inline: "nearest" });
|
||||||
|
scrollState.top = textareaRef.value.scrollTop;
|
||||||
|
scrollState.left = textareaRef.value.scrollLeft;
|
||||||
|
applyScrollState();
|
||||||
|
searchMatchIndex.value = matchIndex;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const findNext = () => {
|
||||||
|
if (searchError.value || !textareaRef.value || 1 > searchMatchList.value.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const { selectionEnd } = textareaRef.value;
|
||||||
|
const nextIndex = searchMatchList.value.findIndex((match) => match.start >= selectionEnd);
|
||||||
|
const targetIndex = 0 <= nextIndex ? nextIndex : 0;
|
||||||
|
return selectMatch(searchMatchList.value[targetIndex], targetIndex);
|
||||||
|
};
|
||||||
|
|
||||||
|
const findPrevious = () => {
|
||||||
|
if (searchError.value || !textareaRef.value || 1 > searchMatchList.value.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const { selectionStart } = textareaRef.value;
|
||||||
|
let targetIndex = searchMatchList.value.length - 1;
|
||||||
|
for (let index = searchMatchList.value.length - 1; 0 <= index; index -= 1) {
|
||||||
|
if (searchMatchList.value[index].start < selectionStart) {
|
||||||
|
targetIndex = index;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return selectMatch(searchMatchList.value[targetIndex], targetIndex);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSearchNext = (shiftKey: boolean) => {
|
||||||
|
if (shiftKey) {
|
||||||
|
findPrevious();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
findNext();
|
||||||
|
};
|
||||||
|
|
||||||
|
const replaceSelection = (match: TextSearchMatch) => {
|
||||||
|
const nextText = replaceMatchText(match.text, searchKeyword.value, replaceKeyword.value, searchOptions);
|
||||||
|
currentValue.value = currentValue.value.slice(0, match.start) + nextText + currentValue.value.slice(match.end);
|
||||||
|
emit("update:modelValue", currentValue.value);
|
||||||
|
emit("change", currentValue.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const replaceCurrent = () => {
|
||||||
|
if (props.readonly || !textareaRef.value || !searchKeyword.value) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const { selectionStart, selectionEnd } = textareaRef.value;
|
||||||
|
let currentMatchIndex = searchMatchList.value.findIndex((match) => (
|
||||||
|
match.start === selectionStart && match.end === selectionEnd
|
||||||
|
));
|
||||||
|
if (0 > currentMatchIndex) {
|
||||||
|
if (!findNext()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
currentMatchIndex = searchMatchIndex.value;
|
||||||
|
}
|
||||||
|
const currentMatch = searchMatchList.value[currentMatchIndex];
|
||||||
|
if (!currentMatch) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
replaceSelection(currentMatch);
|
||||||
|
refreshSearchMatchState();
|
||||||
|
nextTick(() => {
|
||||||
|
const nextMatch = searchMatchList.value[currentMatchIndex] || searchMatchList.value[currentMatchIndex - 1];
|
||||||
|
if (!nextMatch) {
|
||||||
|
focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
selectMatch(nextMatch, Math.min(currentMatchIndex, searchMatchList.value.length - 1));
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const replaceAll = () => {
|
||||||
|
if (props.readonly || !searchKeyword.value) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const result = replaceAllText(currentValue.value, searchKeyword.value, replaceKeyword.value, searchOptions);
|
||||||
|
searchError.value = result.error;
|
||||||
|
if (result.error || 1 > result.count) {
|
||||||
|
refreshSearchMatchState();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
currentValue.value = result.value;
|
||||||
|
emit("update:modelValue", currentValue.value);
|
||||||
|
emit("change", currentValue.value);
|
||||||
|
refreshSearchMatchState();
|
||||||
|
nextTick(() => {
|
||||||
|
if (searchMatchList.value[0]) {
|
||||||
|
selectMatch(searchMatchList.value[0], 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
focus();
|
||||||
|
});
|
||||||
|
return result.count;
|
||||||
|
};
|
||||||
|
|
||||||
|
const focus = () => {
|
||||||
|
textareaRef.value?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
const blur = () => {
|
||||||
|
textareaRef.value?.blur();
|
||||||
|
};
|
||||||
|
|
||||||
|
onActivated(() => {
|
||||||
|
nextTick(() => {
|
||||||
|
applyScrollState();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
rootRef,
|
||||||
|
textareaRef,
|
||||||
|
focus,
|
||||||
|
blur,
|
||||||
|
getScrollState,
|
||||||
|
setScrollState,
|
||||||
|
findNext,
|
||||||
|
findPrevious,
|
||||||
|
replaceCurrent,
|
||||||
|
replaceAll,
|
||||||
|
openSearch,
|
||||||
|
closeSearch
|
||||||
|
});
|
||||||
|
|
||||||
|
function escapeHTML(value: string) {
|
||||||
|
return value
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll("\"", """)
|
||||||
|
.replaceAll("'", "'");
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
type EditorSelection = {
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type EditorEditResult = EditorSelection & {
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const TAB = "\t";
|
||||||
|
const OPEN_CHAR_LIST = new Set(["(", "[", "{"]);
|
||||||
|
const WRAP_CHAR_MAP: Record<string, string> = {
|
||||||
|
"(": ")",
|
||||||
|
"[": "]",
|
||||||
|
"{": "}",
|
||||||
|
"\"": "\"",
|
||||||
|
"'": "'",
|
||||||
|
"`": "`",
|
||||||
|
"<": ">"
|
||||||
|
};
|
||||||
|
const CLOSE_CHAR_LIST = new Set(Object.values(WRAP_CHAR_MAP));
|
||||||
|
|
||||||
|
export const resolveCodeEditorKeydown = (
|
||||||
|
value: string,
|
||||||
|
selection: EditorSelection,
|
||||||
|
event: KeyboardEvent
|
||||||
|
): EditorEditResult | undefined => {
|
||||||
|
if (event.isComposing || event.ctrlKey || event.altKey || event.metaKey) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (event.key === "Tab") {
|
||||||
|
return resolveTabInput(value, selection, event.shiftKey);
|
||||||
|
}
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
return resolveEnterInput(value, selection);
|
||||||
|
}
|
||||||
|
if (event.key === "Backspace") {
|
||||||
|
return resolvePairBackspace(value, selection);
|
||||||
|
}
|
||||||
|
return resolvePairInput(value, selection, event.key);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理 Tab / Shift+Tab 缩进
|
||||||
|
const resolveTabInput = (
|
||||||
|
value: string,
|
||||||
|
selection: EditorSelection,
|
||||||
|
shiftKey: boolean
|
||||||
|
): EditorEditResult => {
|
||||||
|
const { start, end } = selection;
|
||||||
|
|
||||||
|
if (!shiftKey && start === end) {
|
||||||
|
return {
|
||||||
|
value: value.slice(0, start) + TAB + value.slice(end),
|
||||||
|
start: start + 1,
|
||||||
|
end: start + 1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const lineStart = value.lastIndexOf("\n", Math.max(start - 1, 0)) + 1;
|
||||||
|
const lineEndIndex = value.indexOf("\n", end);
|
||||||
|
const blockEnd = lineEndIndex < 0 ? value.length : lineEndIndex;
|
||||||
|
const selectedBlock = value.slice(lineStart, blockEnd);
|
||||||
|
const lineList = selectedBlock.split("\n");
|
||||||
|
|
||||||
|
let nextStart = start;
|
||||||
|
let nextEnd = end;
|
||||||
|
const nextBlock = lineList.map((line, index) => {
|
||||||
|
if (shiftKey) {
|
||||||
|
if (line.startsWith(TAB)) {
|
||||||
|
if (index === 0) {
|
||||||
|
nextStart = Math.max(lineStart, nextStart - 1);
|
||||||
|
}
|
||||||
|
nextEnd = Math.max(nextStart, nextEnd - 1);
|
||||||
|
return line.slice(1);
|
||||||
|
}
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
if (index === 0) {
|
||||||
|
nextStart += 1;
|
||||||
|
}
|
||||||
|
nextEnd += 1;
|
||||||
|
return TAB + line;
|
||||||
|
}).join("\n");
|
||||||
|
|
||||||
|
return {
|
||||||
|
value: value.slice(0, lineStart) + nextBlock + value.slice(blockEnd),
|
||||||
|
start: nextStart,
|
||||||
|
end: nextEnd
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// 回车时继承当前行缩进,位于块级起始括号后时额外增加一层缩进
|
||||||
|
const resolveEnterInput = (
|
||||||
|
value: string,
|
||||||
|
selection: EditorSelection
|
||||||
|
): EditorEditResult => {
|
||||||
|
const { start, end } = selection;
|
||||||
|
const lineStart = value.lastIndexOf("\n", Math.max(start - 1, 0)) + 1;
|
||||||
|
const lineIndent = getLineIndent(value.slice(lineStart, start));
|
||||||
|
const prevChar = value[start - 1] ?? "";
|
||||||
|
const nextChar = value[end] ?? "";
|
||||||
|
const isWrappedBlock = start === end
|
||||||
|
&& OPEN_CHAR_LIST.has(prevChar)
|
||||||
|
&& WRAP_CHAR_MAP[prevChar] === nextChar;
|
||||||
|
|
||||||
|
if (isWrappedBlock) {
|
||||||
|
const nextValue = value.slice(0, start)
|
||||||
|
+ `\n${lineIndent}${TAB}\n${lineIndent}`
|
||||||
|
+ value.slice(end);
|
||||||
|
const nextCursor = start + 1 + lineIndent.length + TAB.length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
value: nextValue,
|
||||||
|
start: nextCursor,
|
||||||
|
end: nextCursor
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const extraIndent = OPEN_CHAR_LIST.has(prevChar) ? TAB : "";
|
||||||
|
const nextValue = value.slice(0, start)
|
||||||
|
+ `\n${lineIndent}${extraIndent}`
|
||||||
|
+ value.slice(end);
|
||||||
|
const nextCursor = start + 1 + lineIndent.length + extraIndent.length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
value: nextValue,
|
||||||
|
start: nextCursor,
|
||||||
|
end: nextCursor
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理成对符号输入,选中文本时直接包裹
|
||||||
|
const resolvePairInput = (
|
||||||
|
value: string,
|
||||||
|
selection: EditorSelection,
|
||||||
|
key: string
|
||||||
|
): EditorEditResult | undefined => {
|
||||||
|
const wrapChar = WRAP_CHAR_MAP[key];
|
||||||
|
if (wrapChar) {
|
||||||
|
return wrapSelection(value, selection, key, wrapChar);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (CLOSE_CHAR_LIST.has(key) && selection.start === selection.end && value[selection.start] === key) {
|
||||||
|
return {
|
||||||
|
value,
|
||||||
|
start: selection.start + 1,
|
||||||
|
end: selection.start + 1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 光标位于成对符号中间时,退格一次同时删除左右字符
|
||||||
|
const resolvePairBackspace = (
|
||||||
|
value: string,
|
||||||
|
selection: EditorSelection
|
||||||
|
): EditorEditResult | undefined => {
|
||||||
|
const { start, end } = selection;
|
||||||
|
if (start !== end || start < 1) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prevChar = value[start - 1];
|
||||||
|
const nextChar = value[start];
|
||||||
|
if (WRAP_CHAR_MAP[prevChar] !== nextChar) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
value: value.slice(0, start - 1) + value.slice(start + 1),
|
||||||
|
start: start - 1,
|
||||||
|
end: start - 1
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// 将当前选区替换为一对包裹字符,并把光标移动到合理位置
|
||||||
|
const wrapSelection = (
|
||||||
|
value: string,
|
||||||
|
selection: EditorSelection,
|
||||||
|
openChar: string,
|
||||||
|
closeChar: string
|
||||||
|
): EditorEditResult => {
|
||||||
|
const { start, end } = selection;
|
||||||
|
const selectedText = value.slice(start, end);
|
||||||
|
const nextValue = value.slice(0, start) + openChar + selectedText + closeChar + value.slice(end);
|
||||||
|
|
||||||
|
if (start === end) {
|
||||||
|
return {
|
||||||
|
value: nextValue,
|
||||||
|
start: start + 1,
|
||||||
|
end: start + 1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
value: nextValue,
|
||||||
|
start: start + 1,
|
||||||
|
end: end + 1
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLineIndent = (value: string) => {
|
||||||
|
const match = value.match(/^[\t ]*/);
|
||||||
|
|
||||||
|
return match?.[0] ?? "";
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
.tui-code-editor {
|
||||||
|
width: calc(100% - 2px);
|
||||||
|
min-width: 0;
|
||||||
|
border: 1px solid var(--td-component-stroke, #B8BBC9);
|
||||||
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: var(--td-radius-medium, 0);
|
||||||
|
|
||||||
|
.gutter {
|
||||||
|
flex: none;
|
||||||
|
color: var(--td-text-color-placeholder, #999);
|
||||||
|
overflow: hidden;
|
||||||
|
text-align: right;
|
||||||
|
background: #F2F2F2E6;
|
||||||
|
border-right: 1px solid var(--td-component-stroke, #B8BBC9);
|
||||||
|
|
||||||
|
> span {
|
||||||
|
width: 3.25rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
display: block;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-family: "JetBrains Mono", "Fira Code", Consolas, "Courier New", monospace;
|
||||||
|
line-height: 1.5rem;
|
||||||
|
padding-right: .25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-bar {
|
||||||
|
top: .5rem;
|
||||||
|
right: .5rem;
|
||||||
|
gap: .5rem;
|
||||||
|
z-index: 3;
|
||||||
|
display: flex;
|
||||||
|
padding: .5rem;
|
||||||
|
position: absolute;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: var(--td-radius-default, .375rem);
|
||||||
|
background: var(--td-bg-color-container, #fff);
|
||||||
|
border: 1px solid var(--td-component-stroke, #B8BBC9);
|
||||||
|
box-shadow: 0 .25rem .75rem rgba(0, 0, 0, .12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
width: 10rem;
|
||||||
|
height: 2rem;
|
||||||
|
padding: 0 .5rem;
|
||||||
|
color: var(--td-text-color-primary, #111);
|
||||||
|
border: 1px solid var(--td-component-stroke, #B8BBC9);
|
||||||
|
background: var(--td-bg-color-container, #fff);
|
||||||
|
border-radius: var(--td-radius-default, .375rem);
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-actions {
|
||||||
|
gap: .25rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-button {
|
||||||
|
height: 2rem;
|
||||||
|
padding: 0 .625rem;
|
||||||
|
color: var(--td-text-color-primary, #111);
|
||||||
|
border: 1px solid var(--td-component-stroke, #B8BBC9);
|
||||||
|
background: var(--td-bg-color-container, #fff);
|
||||||
|
border-radius: var(--td-radius-default, .375rem);
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--td-bg-color-container-hover, #f6f6f6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-status {
|
||||||
|
color: var(--td-text-color-secondary, #666);
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: .875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview,
|
||||||
|
.input {
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
tab-size: 4;
|
||||||
|
overflow: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
line-height: 1.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-family: "JetBrains Mono", "Fira Code", Consolas, "Courier New", monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview {
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
border: none;
|
||||||
|
position: absolute;
|
||||||
|
word-break: normal;
|
||||||
|
background: transparent;
|
||||||
|
transition: none;
|
||||||
|
white-space: pre;
|
||||||
|
border-radius: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
code {
|
||||||
|
padding: 0;
|
||||||
|
display: block;
|
||||||
|
width: max-content;
|
||||||
|
min-width: 100%;
|
||||||
|
min-height: 100%;
|
||||||
|
color: inherit;
|
||||||
|
background: transparent;
|
||||||
|
text-shadow: none;
|
||||||
|
font-size: inherit;
|
||||||
|
line-height: inherit;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
color: transparent;
|
||||||
|
resize: none;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
position: absolute;
|
||||||
|
background: transparent;
|
||||||
|
white-space: pre;
|
||||||
|
word-break: normal;
|
||||||
|
overflow-wrap: normal;
|
||||||
|
caret-color: var(--td-text-color-primary, #111);
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input::placeholder {
|
||||||
|
color: var(--td-text-color-placeholder, #999);
|
||||||
|
-webkit-text-fill-color: var(--td-text-color-placeholder, #999);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input::selection {
|
||||||
|
background: rgb(0 122 255 / 18%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.disabled {
|
||||||
|
opacity: .65;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.readonly {
|
||||||
|
.preview {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.tui-code-editor {
|
||||||
|
border-color: var(--td-component-stroke, #434343);
|
||||||
|
|
||||||
|
.gutter {
|
||||||
|
border-right-color: var(--td-component-stroke, #434343);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-bar {
|
||||||
|
background: var(--td-bg-color-container, #1f1f1f);
|
||||||
|
border-color: var(--td-component-stroke, #434343);
|
||||||
|
box-shadow: 0 .25rem .75rem rgba(0, 0, 0, .28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input,
|
||||||
|
.search-button {
|
||||||
|
color: var(--td-text-color-primary, #F5F5F5);
|
||||||
|
border-color: var(--td-component-stroke, #434343);
|
||||||
|
background: var(--td-bg-color-container, #1f1f1f);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-button:hover {
|
||||||
|
background: var(--td-bg-color-container-hover, #2b2b2b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
caret-color: var(--td-text-color-primary, #F5F5F5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input::selection {
|
||||||
|
background: rgb(64 153 255 / 28%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import view from "./index.vue";
|
||||||
|
import Toolkit from "../../utils/Toolkit";
|
||||||
|
|
||||||
|
export const ContactNumber = Toolkit.withInstall(view);
|
||||||
|
export default ContactNumber;
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
<template>
|
||||||
|
<span class="tui-contact-number">
|
||||||
|
<a v-if="value" :href="`tel:${dialValue}`">
|
||||||
|
<span v-if="$slots.icon" class="icon">
|
||||||
|
<slot name="icon"></slot>
|
||||||
|
</span>
|
||||||
|
<span :class="{ underline }" v-text="formattedValue"></span>
|
||||||
|
</a>
|
||||||
|
<span v-else>
|
||||||
|
<span v-if="$slots.icon" class="icon">
|
||||||
|
<slot name="icon"></slot>
|
||||||
|
</span>
|
||||||
|
<span :class="{ underline }" v-text="formattedValue"></span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineOptions({
|
||||||
|
name: "ContactNumber"
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
value?: string | number;
|
||||||
|
underline?: boolean;
|
||||||
|
separator?: " " | "-";
|
||||||
|
}>(), {
|
||||||
|
underline: false,
|
||||||
|
separator: " "
|
||||||
|
});
|
||||||
|
|
||||||
|
const { underline, separator } = toRefs(props);
|
||||||
|
|
||||||
|
/** 将号码按指定长度分组。 */
|
||||||
|
function splitNumber(number: string, lengths: number[], separator: " " | "-"): string {
|
||||||
|
let start = 0;
|
||||||
|
const groups: string[] = [];
|
||||||
|
for (const length of lengths) {
|
||||||
|
groups.push(number.slice(start, start + length));
|
||||||
|
start += length;
|
||||||
|
}
|
||||||
|
if (start < number.length) {
|
||||||
|
groups.push(number.slice(start));
|
||||||
|
}
|
||||||
|
return groups.filter(Boolean).join(separator);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 格式化号码展示文本,保留开头的国际区号。 */
|
||||||
|
function formatNumber(number: string): string {
|
||||||
|
const normalized = number.replace(/[^\d+]/g, "").replace(/(?!^)\+/g, "");
|
||||||
|
const digits = normalized.replace(/\D/g, "");
|
||||||
|
if (digits.length < 8) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
if (normalized.startsWith("+")) {
|
||||||
|
const countryCode = digits.slice(0, 2);
|
||||||
|
const localNumber = digits.slice(2);
|
||||||
|
|
||||||
|
if (localNumber.length >= 11) {
|
||||||
|
return `+${countryCode}${separator.value}${splitNumber(localNumber, [3, 4], separator.value)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `+${countryCode}${separator.value}${splitNumber(localNumber, [4], separator.value)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (digits.length === 11) {
|
||||||
|
return splitNumber(digits, [3, 4], separator.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return splitNumber(digits, [4], separator.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = computed(() => props.value !== undefined && props.value !== null && String(props.value).trim() !== "");
|
||||||
|
const dialValue = computed(() => String(props.value ?? "").replace(/[^\d+]/g, "").replace(/(?!^)\+/g, ""));
|
||||||
|
const formattedValue = computed(() => formatNumber(String(props.value ?? "").trim()));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.tui-contact-number {
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
display: inline-flex;
|
||||||
|
margin-right: .25rem;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
span {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.underline {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="visible" class="tui-editor-search" @mousedown.stop>
|
||||||
|
<div class="row">
|
||||||
|
<t-button
|
||||||
|
class="toggle-btn"
|
||||||
|
shape="square"
|
||||||
|
size="small"
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
:title="expanded ? '收起替换' : '展开替换'"
|
||||||
|
@click="emit('update:expanded', !expanded)"
|
||||||
|
>
|
||||||
|
<t-icon :name="expanded ? 'chevron-down-s' : 'chevron-right-s'" />
|
||||||
|
</t-button>
|
||||||
|
<t-input
|
||||||
|
ref="keywordInputRef"
|
||||||
|
:value="keyword"
|
||||||
|
class="text-input"
|
||||||
|
placeholder="查找"
|
||||||
|
size="small"
|
||||||
|
@change="onKeywordChange"
|
||||||
|
@enter="onKeywordEnter"
|
||||||
|
@keydown="onKeywordKeydown"
|
||||||
|
>
|
||||||
|
<template #suffix-icon>
|
||||||
|
<t-button
|
||||||
|
shape="square"
|
||||||
|
size="small"
|
||||||
|
type="button"
|
||||||
|
variant="text"
|
||||||
|
:theme="caseSensitive ? 'primary' : 'default'"
|
||||||
|
title="大小写匹配"
|
||||||
|
@click="emit('update:case-sensitive', !caseSensitive)"
|
||||||
|
>
|
||||||
|
Aa
|
||||||
|
</t-button>
|
||||||
|
<t-button
|
||||||
|
shape="square"
|
||||||
|
size="small"
|
||||||
|
type="button"
|
||||||
|
variant="text"
|
||||||
|
:theme="useRegex ? 'primary' : 'default'"
|
||||||
|
title="正则搜索"
|
||||||
|
@click="emit('update:use-regex', !useRegex)"
|
||||||
|
>
|
||||||
|
.*
|
||||||
|
</t-button>
|
||||||
|
</template>
|
||||||
|
</t-input>
|
||||||
|
<span class="status" v-text="resolvedStatusText" />
|
||||||
|
<t-button shape="square" size="small" type="button" variant="text" title="上一个" @click="emit('previous')">
|
||||||
|
<t-icon name="arrow-up" />
|
||||||
|
</t-button>
|
||||||
|
<t-button shape="square" size="small" type="button" variant="text" title="下一个" @click="emit('next', false)">
|
||||||
|
<t-icon name="arrow-down" />
|
||||||
|
</t-button>
|
||||||
|
<t-button shape="square" size="small" type="button" variant="text" title="关闭" @click="emit('close')">
|
||||||
|
<t-icon name="close" />
|
||||||
|
</t-button>
|
||||||
|
</div>
|
||||||
|
<div v-if="expanded" class="row replace-row">
|
||||||
|
<div class="toggle-holder" />
|
||||||
|
<t-input
|
||||||
|
ref="replaceInputRef"
|
||||||
|
:value="replaceValue"
|
||||||
|
class="text-input"
|
||||||
|
placeholder="替换"
|
||||||
|
size="small"
|
||||||
|
@change="onReplaceChange"
|
||||||
|
@enter="emit('replace')"
|
||||||
|
@keydown="onReplaceKeydown"
|
||||||
|
/>
|
||||||
|
<div class="replace-actions">
|
||||||
|
<t-button size="small" type="button" theme="primary" @click="emit('replace')">替换</t-button>
|
||||||
|
<t-button size="small" type="button" variant="outline" @click="emit('replace-all')">全部替换</t-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { ComponentPublicInstance } from "vue";
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: "EditorSearchBar"
|
||||||
|
});
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
visible?: boolean;
|
||||||
|
expanded?: boolean;
|
||||||
|
keyword?: string;
|
||||||
|
replaceValue?: string;
|
||||||
|
statusText?: string;
|
||||||
|
statusError?: string;
|
||||||
|
matchIndex?: number;
|
||||||
|
matchCount?: number;
|
||||||
|
caseSensitive?: boolean;
|
||||||
|
useRegex?: boolean;
|
||||||
|
}>(), {
|
||||||
|
visible: false,
|
||||||
|
expanded: false,
|
||||||
|
keyword: "",
|
||||||
|
replaceValue: "",
|
||||||
|
statusText: undefined,
|
||||||
|
statusError: "",
|
||||||
|
matchIndex: -1,
|
||||||
|
matchCount: 0,
|
||||||
|
caseSensitive: false,
|
||||||
|
useRegex: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: "update:expanded", value: boolean): void;
|
||||||
|
(event: "update:keyword", value: string): void;
|
||||||
|
(event: "update:replace-value", value: string): void;
|
||||||
|
(event: "update:case-sensitive", value: boolean): void;
|
||||||
|
(event: "update:use-regex", value: boolean): void;
|
||||||
|
(event: "previous"): void;
|
||||||
|
(event: "next", shiftKey: boolean): void;
|
||||||
|
(event: "replace"): void;
|
||||||
|
(event: "replace-all"): void;
|
||||||
|
(event: "close"): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
type InputInstance = ComponentPublicInstance & {
|
||||||
|
$el: HTMLElement;
|
||||||
|
};
|
||||||
|
|
||||||
|
type InputKeyboardContext = {
|
||||||
|
e: KeyboardEvent;
|
||||||
|
};
|
||||||
|
|
||||||
|
const keywordInputRef = ref<InputInstance>();
|
||||||
|
const replaceInputRef = ref<InputInstance>();
|
||||||
|
|
||||||
|
const resolvedStatusText = computed(() => {
|
||||||
|
if (undefined !== props.statusText) {
|
||||||
|
return props.statusText;
|
||||||
|
}
|
||||||
|
if (props.statusError) {
|
||||||
|
return props.statusError;
|
||||||
|
}
|
||||||
|
if (!props.keyword) {
|
||||||
|
return "无结果";
|
||||||
|
}
|
||||||
|
if (1 > props.matchCount) {
|
||||||
|
return "未找到";
|
||||||
|
}
|
||||||
|
return `${props.matchIndex + 1} / ${props.matchCount}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const onKeywordChange = (value: string | number) => {
|
||||||
|
emit("update:keyword", `${value}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onReplaceChange = (value: string | number) => {
|
||||||
|
emit("update:replace-value", `${value}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onKeywordEnter = (_value: string | number, context: InputKeyboardContext) => {
|
||||||
|
emit("next", context.e.shiftKey);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onKeywordKeydown = (_value: string | number, context: InputKeyboardContext) => {
|
||||||
|
if ("Escape" !== context.e.key) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
context.e.preventDefault();
|
||||||
|
emit("close");
|
||||||
|
};
|
||||||
|
|
||||||
|
const onReplaceKeydown = (_value: string | number, context: InputKeyboardContext) => {
|
||||||
|
if ("Escape" !== context.e.key) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
context.e.preventDefault();
|
||||||
|
emit("close");
|
||||||
|
};
|
||||||
|
|
||||||
|
const getNativeInput = (instance?: InputInstance) => instance?.$el.querySelector("input") || undefined;
|
||||||
|
|
||||||
|
const focusKeyword = (selectAll = false) => {
|
||||||
|
const input = getNativeInput(keywordInputRef.value);
|
||||||
|
input?.focus();
|
||||||
|
if (selectAll && input) {
|
||||||
|
input.selectionStart = 0;
|
||||||
|
input.selectionEnd = input.value.length;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const focusReplace = (selectAll = false) => {
|
||||||
|
const input = getNativeInput(replaceInputRef.value);
|
||||||
|
input?.focus();
|
||||||
|
if (selectAll && input) {
|
||||||
|
input.selectionStart = 0;
|
||||||
|
input.selectionEnd = input.value.length;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
focusKeyword,
|
||||||
|
focusReplace,
|
||||||
|
keywordInputRef,
|
||||||
|
replaceInputRef
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.tui-editor-search {
|
||||||
|
top: .5rem;
|
||||||
|
right: .5rem;
|
||||||
|
gap: .375rem;
|
||||||
|
z-index: 3;
|
||||||
|
display: flex;
|
||||||
|
padding: .5rem;
|
||||||
|
position: absolute;
|
||||||
|
min-width: 20rem;
|
||||||
|
max-width: calc(100% - 1rem);
|
||||||
|
box-sizing: border-box;
|
||||||
|
border-radius: var(--td-radius-default, .375rem);
|
||||||
|
background: var(--td-bg-color-container, #fff);
|
||||||
|
border: 1px solid var(--td-component-stroke, #B8BBC9);
|
||||||
|
box-shadow: 0 .25rem .75rem rgba(0, 0, 0, .12);
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.row {
|
||||||
|
gap: .375rem;
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.toggle-holder {
|
||||||
|
flex: none;
|
||||||
|
width: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-btn {
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-input {
|
||||||
|
width: 12rem;
|
||||||
|
|
||||||
|
:deep(.t-input) {
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
width: 3rem;
|
||||||
|
color: var(--td-text-color-secondary, #666);
|
||||||
|
font-size: .875rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.replace-actions {
|
||||||
|
gap: .375rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.tui-editor-search {
|
||||||
|
background: var(--td-bg-color-container, #1f1f1f);
|
||||||
|
border-color: var(--td-component-stroke, #434343);
|
||||||
|
box-shadow: 0 .25rem .75rem rgba(0, 0, 0, .28);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
export type TextSearchOptions = {
|
||||||
|
caseSensitive: boolean;
|
||||||
|
useRegex: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TextSearchMatch = {
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TextSearchResult = {
|
||||||
|
matches: TextSearchMatch[];
|
||||||
|
error: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildFlags = (options: TextSearchOptions, global = true) => {
|
||||||
|
let flags = global ? "g" : "";
|
||||||
|
if (!options.caseSensitive) {
|
||||||
|
flags += "i";
|
||||||
|
}
|
||||||
|
return flags;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const escapeSearchKeyword = (value: string) => (
|
||||||
|
value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
||||||
|
);
|
||||||
|
|
||||||
|
export const buildSearchRegex = (
|
||||||
|
keyword: string,
|
||||||
|
options: TextSearchOptions,
|
||||||
|
global = true
|
||||||
|
) => {
|
||||||
|
if (!keyword) {
|
||||||
|
return { regex: undefined, error: "" };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const source = options.useRegex ? keyword : escapeSearchKeyword(keyword);
|
||||||
|
const regex = new RegExp(source, buildFlags(options, global));
|
||||||
|
const emptyMatch = "".match(regex);
|
||||||
|
if (emptyMatch && emptyMatch[0] === "") {
|
||||||
|
return {
|
||||||
|
regex: undefined,
|
||||||
|
error: "正则不能匹配空文本"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { regex, error: "" };
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
regex: undefined,
|
||||||
|
error: "正则表达式无效"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const findTextMatches = (
|
||||||
|
value: string,
|
||||||
|
keyword: string,
|
||||||
|
options: TextSearchOptions
|
||||||
|
): TextSearchResult => {
|
||||||
|
const { regex, error } = buildSearchRegex(keyword, options, true);
|
||||||
|
if (!regex || error) {
|
||||||
|
return {
|
||||||
|
matches: [],
|
||||||
|
error
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const matches: TextSearchMatch[] = [];
|
||||||
|
let match = regex.exec(value);
|
||||||
|
while (match) {
|
||||||
|
if (!match[0]) {
|
||||||
|
return {
|
||||||
|
matches: [],
|
||||||
|
error: "正则不能匹配空文本"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
matches.push({
|
||||||
|
start: match.index,
|
||||||
|
end: match.index + match[0].length,
|
||||||
|
text: match[0]
|
||||||
|
});
|
||||||
|
match = regex.exec(value);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
matches,
|
||||||
|
error: ""
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const replaceMatchText = (
|
||||||
|
matchText: string,
|
||||||
|
keyword: string,
|
||||||
|
replaceText: string,
|
||||||
|
options: TextSearchOptions
|
||||||
|
) => {
|
||||||
|
if (!options.useRegex) {
|
||||||
|
return replaceText;
|
||||||
|
}
|
||||||
|
const { regex, error } = buildSearchRegex(keyword, options, false);
|
||||||
|
if (!regex || error) {
|
||||||
|
return matchText;
|
||||||
|
}
|
||||||
|
return matchText.replace(regex, replaceText);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const replaceAllText = (
|
||||||
|
value: string,
|
||||||
|
keyword: string,
|
||||||
|
replaceText: string,
|
||||||
|
options: TextSearchOptions
|
||||||
|
) => {
|
||||||
|
const { matches, error } = findTextMatches(value, keyword, options);
|
||||||
|
if (error || 1 > matches.length) {
|
||||||
|
return {
|
||||||
|
value,
|
||||||
|
count: 0,
|
||||||
|
error
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let nextValue = "";
|
||||||
|
let lastEnd = 0;
|
||||||
|
matches.forEach((match) => {
|
||||||
|
nextValue += value.slice(lastEnd, match.start);
|
||||||
|
nextValue += replaceMatchText(match.text, keyword, replaceText, options);
|
||||||
|
lastEnd = match.end;
|
||||||
|
});
|
||||||
|
nextValue += value.slice(lastEnd);
|
||||||
|
return {
|
||||||
|
value: nextValue,
|
||||||
|
count: matches.length,
|
||||||
|
error: ""
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
+38
-2
@@ -9,6 +9,13 @@ import EmptyTips from "./empty-tips";
|
|||||||
import MarkdownView from "./markdown-view";
|
import MarkdownView from "./markdown-view";
|
||||||
import BEFlowerFall from "./background-effect/flower-fall";
|
import BEFlowerFall from "./background-effect/flower-fall";
|
||||||
import MarkdownEditor from "./markdown-editor";
|
import MarkdownEditor from "./markdown-editor";
|
||||||
|
import PassTimeLabel from "./passtime-label";
|
||||||
|
import ContactNumber from "./contact-number";
|
||||||
|
import CodeEditor from "./code-editor";
|
||||||
|
import TiptapEditor from "./tiptap-editor";
|
||||||
|
import TiptapView from "./tiptap-view";
|
||||||
|
import ArticleEditor from "./article-editor";
|
||||||
|
import ArticleView from "./article-view";
|
||||||
|
|
||||||
export default [
|
export default [
|
||||||
Icon,
|
Icon,
|
||||||
@@ -20,7 +27,14 @@ export default [
|
|||||||
EmptyTips,
|
EmptyTips,
|
||||||
MarkdownView,
|
MarkdownView,
|
||||||
BEFlowerFall,
|
BEFlowerFall,
|
||||||
MarkdownEditor
|
MarkdownEditor,
|
||||||
|
PassTimeLabel,
|
||||||
|
ContactNumber,
|
||||||
|
CodeEditor,
|
||||||
|
TiptapEditor,
|
||||||
|
TiptapView,
|
||||||
|
ArticleEditor,
|
||||||
|
ArticleView
|
||||||
];
|
];
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -33,5 +47,27 @@ export {
|
|||||||
EmptyTips,
|
EmptyTips,
|
||||||
MarkdownView,
|
MarkdownView,
|
||||||
BEFlowerFall,
|
BEFlowerFall,
|
||||||
MarkdownEditor
|
MarkdownEditor,
|
||||||
|
PassTimeLabel,
|
||||||
|
ContactNumber,
|
||||||
|
CodeEditor,
|
||||||
|
TiptapEditor,
|
||||||
|
TiptapView,
|
||||||
|
ArticleEditor,
|
||||||
|
ArticleView
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type {
|
||||||
|
CodeEditorExpose
|
||||||
|
} from "./code-editor";
|
||||||
|
|
||||||
|
export type {
|
||||||
|
UploadResult,
|
||||||
|
AlignType,
|
||||||
|
TiptapEditorExpose,
|
||||||
|
TiptapEditorInstance
|
||||||
|
} from "./tiptap-editor";
|
||||||
|
|
||||||
|
export type {
|
||||||
|
ArticleEditorExpose
|
||||||
|
} from "./article-editor";
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import view from "./index.vue";
|
||||||
|
import Toolkit from "../../utils/Toolkit";
|
||||||
|
|
||||||
|
export const PassTimeLabel = Toolkit.withInstall(view);
|
||||||
|
export default PassTimeLabel;
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<template>
|
||||||
|
<span
|
||||||
|
v-if="timestamp"
|
||||||
|
class="tui-passtime-label"
|
||||||
|
:class="{ underline }"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
v-text="content"
|
||||||
|
@click="toggle"
|
||||||
|
@keydown.enter.space.prevent="toggle"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import Time from "../../utils/Time";
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: "PassTimeLabel"
|
||||||
|
});
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
timestamp?: number;
|
||||||
|
onlyTime?: boolean;
|
||||||
|
onlyDate?: boolean;
|
||||||
|
underline?: boolean;
|
||||||
|
}>(), {
|
||||||
|
onlyTime: false,
|
||||||
|
onlyDate: false,
|
||||||
|
underline: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const { timestamp, onlyTime, onlyDate } = toRefs(props);
|
||||||
|
const showingDetail = ref(false);
|
||||||
|
|
||||||
|
const content = computed(() => {
|
||||||
|
if (!showingDetail.value) {
|
||||||
|
return Time.toPassedDateTime(timestamp.value);
|
||||||
|
}
|
||||||
|
if (onlyTime.value) {
|
||||||
|
return Time.toTime(timestamp.value);
|
||||||
|
}
|
||||||
|
if (onlyDate.value) {
|
||||||
|
return Time.toDate(timestamp.value);
|
||||||
|
}
|
||||||
|
return Time.toDateTime(timestamp.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
function toggle(): void {
|
||||||
|
showingDetail.value = !showingDetail.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.tui-passtime-label {
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&.underline {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
.tui-markdown-view,
|
||||||
|
.tui-code-editor,
|
||||||
|
.tp-content-body {
|
||||||
|
img,
|
||||||
|
video,
|
||||||
|
iframe.tp-iframe {
|
||||||
|
max-width: 100%;
|
||||||
|
display: block;
|
||||||
|
margin: 1rem auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
height: auto;
|
||||||
|
border-radius: var(--td-radius-default, .375rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
video,
|
||||||
|
iframe.tp-iframe {
|
||||||
|
width: min(100%, 56rem);
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: var(--td-radius-default, .375rem);
|
||||||
|
background: var(--td-bg-color-container, #fff);
|
||||||
|
border: 1px solid var(--td-component-border, #dcdcdc);
|
||||||
|
}
|
||||||
|
|
||||||
|
iframe.tp-iframe {
|
||||||
|
height: 28rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
margin: 1rem 0;
|
||||||
|
border-spacing: 0;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
padding: .5rem .75rem;
|
||||||
|
vertical-align: middle;
|
||||||
|
border: 1px solid var(--td-component-border, #dcdcdc);
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
background: var(--td-bg-color-container-hover, #f6f6f6);
|
||||||
|
}
|
||||||
|
|
||||||
|
pre[class*="language-"] {
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--td-component-stroke, #B8BBC9);
|
||||||
|
overflow: auto;
|
||||||
|
background: transparent;
|
||||||
|
transition: max-height .5s ease;
|
||||||
|
border-radius: var(--td-radius-medium, 0);
|
||||||
|
|
||||||
|
code {
|
||||||
|
color: #333;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: block;
|
||||||
|
font-size: 1rem;
|
||||||
|
background: transparent;
|
||||||
|
text-shadow: none;
|
||||||
|
line-height: 1.5;
|
||||||
|
font-family: "JetBrains Mono", "Fira Code", Consolas, "Courier New", monospace;
|
||||||
|
|
||||||
|
.line-numbers-rows {
|
||||||
|
left: 0;
|
||||||
|
flex: none;
|
||||||
|
width: 3.25rem;
|
||||||
|
display: block;
|
||||||
|
text-align: right;
|
||||||
|
background: #F2F2F2E6;
|
||||||
|
border-right: 1px solid var(--td-component-stroke, #B8BBC9);
|
||||||
|
padding-right: .25rem;
|
||||||
|
|
||||||
|
> span {
|
||||||
|
color: var(--td-text-color-placeholder, #999);
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
> span::before {
|
||||||
|
content: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.codes {
|
||||||
|
display: block;
|
||||||
|
min-width: max-content;
|
||||||
|
padding-left: .25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pre[class*="language-"].has-line-numbers {
|
||||||
|
code {
|
||||||
|
padding: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
min-width: 100%;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-numbers-rows {
|
||||||
|
left: 0;
|
||||||
|
z-index: 1;
|
||||||
|
position: sticky;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codes {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.token.namespace {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token.punctuation {
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token.constant {
|
||||||
|
color: #FF7A9B;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token.annotation {
|
||||||
|
color: purple;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token.function {
|
||||||
|
color: #777;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token.class-name {
|
||||||
|
color: #FF461F;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token.generics .class-name {
|
||||||
|
color: #895532;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token.comment {
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token.string {
|
||||||
|
color: #55AA55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token.number {
|
||||||
|
color: #EB9354;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token.keyword,
|
||||||
|
.token.boolean {
|
||||||
|
color: #177CB0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tui-markdown-view pre[class*="language-"],
|
||||||
|
.tp-content-body pre[class*="language-"] {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tp-editor-body {
|
||||||
|
|
||||||
|
pre[class*="language-"] {
|
||||||
|
max-height: 24rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tui-code-editor {
|
||||||
|
|
||||||
|
pre[class*="language-"] {
|
||||||
|
border: none;
|
||||||
|
padding: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
.tp-content-theme() {
|
||||||
|
color: var(--td-text-color-primary);
|
||||||
|
line-height: 1.6;
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0 0 .75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
p:empty:before {
|
||||||
|
content: "\00a0";
|
||||||
|
}
|
||||||
|
|
||||||
|
ul,
|
||||||
|
ol {
|
||||||
|
margin: 0 0 .75rem;
|
||||||
|
padding-left: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
margin: .75rem 0;
|
||||||
|
table-layout: fixed;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
border: 1px solid var(--td-component-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
background: var(--td-bg-color-secondarycontainer);
|
||||||
|
}
|
||||||
|
|
||||||
|
img,
|
||||||
|
video,
|
||||||
|
iframe {
|
||||||
|
margin: 0 auto;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tp-content-body {
|
||||||
|
.tp-content-theme();
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import CodeBlock from "@tiptap/extension-code-block";
|
||||||
|
import { findChildren, mergeAttributes } from "@tiptap/vue-3";
|
||||||
|
import type { Node as ProsemirrorNode } from "@tiptap/pm/model";
|
||||||
|
import type { EditorState, Transaction } from "@tiptap/pm/state";
|
||||||
|
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||||
|
import { Decoration, DecorationSet } from "@tiptap/pm/view";
|
||||||
|
import Prism from "prismjs";
|
||||||
|
import { resolvePrismLanguage } from "../../utils/Prism";
|
||||||
|
|
||||||
|
type HighlightNode = {
|
||||||
|
text: string;
|
||||||
|
classes: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type PrismToken = {
|
||||||
|
type: string;
|
||||||
|
alias?: string | string[];
|
||||||
|
content: string | PrismToken | Array<string | PrismToken>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type StepRange = {
|
||||||
|
from?: number;
|
||||||
|
to?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildLanguageClassName(languageClassPrefix: string | null | undefined, language?: string | null) {
|
||||||
|
if (!languageClassPrefix || !language) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return `${languageClassPrefix}${language}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTokenClassList(type: string, alias?: string | string[]) {
|
||||||
|
const aliasList = Array.isArray(alias) ? alias : alias ? [alias] : [];
|
||||||
|
return ["token", type, ...aliasList];
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePrismTokens(tokens: Array<string | PrismToken>, className: string[] = []): HighlightNode[] {
|
||||||
|
return tokens.flatMap((token) => {
|
||||||
|
if (typeof token === "string") {
|
||||||
|
return [{
|
||||||
|
text: token,
|
||||||
|
classes: className
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
const classes = [...className, ...buildTokenClassList(token.type, token.alias)];
|
||||||
|
if (typeof token.content === "string") {
|
||||||
|
return [{
|
||||||
|
text: token.content,
|
||||||
|
classes
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
if (Array.isArray(token.content)) {
|
||||||
|
return parsePrismTokens(token.content, classes);
|
||||||
|
}
|
||||||
|
return parsePrismTokens([token.content], classes);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDecorations({
|
||||||
|
doc,
|
||||||
|
name,
|
||||||
|
defaultLanguage
|
||||||
|
}: {
|
||||||
|
doc: ProsemirrorNode;
|
||||||
|
name: string;
|
||||||
|
defaultLanguage: string | null | undefined;
|
||||||
|
}) {
|
||||||
|
const decorations: Decoration[] = [];
|
||||||
|
|
||||||
|
findChildren(doc, node => node.type.name === name).forEach((block) => {
|
||||||
|
let from = block.pos + 1;
|
||||||
|
const prismLanguage = resolvePrismLanguage(block.node.attrs.language || defaultLanguage);
|
||||||
|
const grammar = prismLanguage ? Prism.languages[prismLanguage] : null;
|
||||||
|
const tokens = grammar ? Prism.tokenize(block.node.textContent, grammar) : [block.node.textContent];
|
||||||
|
|
||||||
|
parsePrismTokens(tokens).forEach((node) => {
|
||||||
|
const to = from + node.text.length;
|
||||||
|
if (node.classes.length) {
|
||||||
|
decorations.push(Decoration.inline(from, to, {
|
||||||
|
class: node.classes.join(" ")
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
from = to;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return DecorationSet.create(doc, decorations);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPrismPlugin({
|
||||||
|
name,
|
||||||
|
defaultLanguage
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
defaultLanguage: string | null | undefined;
|
||||||
|
}) {
|
||||||
|
const prismPlugin: Plugin<any> = new Plugin({
|
||||||
|
key: new PluginKey("prism"),
|
||||||
|
state: {
|
||||||
|
init: (_: unknown, { doc }: EditorState) => getDecorations({
|
||||||
|
doc,
|
||||||
|
name,
|
||||||
|
defaultLanguage
|
||||||
|
}),
|
||||||
|
apply: (transaction: Transaction, decorationSet: DecorationSet, oldState: EditorState, newState: EditorState) => {
|
||||||
|
const oldNodeName = oldState.selection.$head.parent.type.name;
|
||||||
|
const newNodeName = newState.selection.$head.parent.type.name;
|
||||||
|
const oldNodes = findChildren(oldState.doc, node => node.type.name === name);
|
||||||
|
const newNodes = findChildren(newState.doc, node => node.type.name === name);
|
||||||
|
|
||||||
|
if (
|
||||||
|
transaction.docChanged
|
||||||
|
&& (
|
||||||
|
[oldNodeName, newNodeName].includes(name)
|
||||||
|
|| newNodes.length !== oldNodes.length
|
||||||
|
|| transaction.steps.some((step) => {
|
||||||
|
const range = step as StepRange;
|
||||||
|
return range.from !== undefined
|
||||||
|
&& range.to !== undefined
|
||||||
|
&& oldNodes.some(node => node.pos >= range.from! && node.pos + node.node.nodeSize <= range.to!);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return getDecorations({
|
||||||
|
doc: transaction.doc,
|
||||||
|
name,
|
||||||
|
defaultLanguage
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return decorationSet.map(transaction.mapping, transaction.doc);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
decorations(state: EditorState) {
|
||||||
|
return prismPlugin.getState(state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return prismPlugin;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CodeBlock.extend({
|
||||||
|
renderHTML({ node, HTMLAttributes }: { node: ProsemirrorNode; HTMLAttributes: Record<string, unknown> }) {
|
||||||
|
const languageClassName = buildLanguageClassName(this.options.languageClassPrefix, node.attrs.language);
|
||||||
|
|
||||||
|
return [
|
||||||
|
"pre",
|
||||||
|
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
|
||||||
|
class: languageClassName
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
"code",
|
||||||
|
{
|
||||||
|
class: languageClassName
|
||||||
|
},
|
||||||
|
0
|
||||||
|
]
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
addProseMirrorPlugins() {
|
||||||
|
return [
|
||||||
|
createPrismPlugin({
|
||||||
|
name: this.name,
|
||||||
|
defaultLanguage: this.options.defaultLanguage
|
||||||
|
})
|
||||||
|
];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<template>
|
||||||
|
<t-popconfirm
|
||||||
|
v-model:visible="visible"
|
||||||
|
trigger="click"
|
||||||
|
:confirm-btn="{ content: '插入', theme: 'primary' }"
|
||||||
|
:cancel-btn="{ content: '取消', theme: 'default' }"
|
||||||
|
placement="bottom"
|
||||||
|
@visible-change="onVisibleChange"
|
||||||
|
@confirm="onConfirm"
|
||||||
|
>
|
||||||
|
<template #icon><span></span></template>
|
||||||
|
<template #content>
|
||||||
|
<t-input v-model="codeLanguage" placeholder="代码语言,可留空" />
|
||||||
|
</template>
|
||||||
|
<t-button v-popup="'代码块'" variant="outline" :theme="active ? 'primary' : 'default'">
|
||||||
|
<t-icon name="code" />
|
||||||
|
</t-button>
|
||||||
|
</t-popconfirm>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { TiptapEditorInstance } from "./useTiptapEditor";
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: "TiptapCodeBlockButton"
|
||||||
|
});
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
editor?: TiptapEditorInstance;
|
||||||
|
active: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const visible = ref(false);
|
||||||
|
const codeLanguage = ref("");
|
||||||
|
|
||||||
|
function onVisibleChange(nextVisible: boolean) {
|
||||||
|
if (!nextVisible || !props.editor) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
codeLanguage.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function onConfirm() {
|
||||||
|
if (!props.editor) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const language = codeLanguage.value.trim();
|
||||||
|
if (!language) {
|
||||||
|
props.editor.chain().focus().setCodeBlock().run();
|
||||||
|
visible.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
props.editor.chain().focus().setCodeBlock({ language }).run();
|
||||||
|
visible.value = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
<template>
|
||||||
|
<t-popconfirm
|
||||||
|
class="tiptap-iframe-button"
|
||||||
|
v-model:visible="visible"
|
||||||
|
trigger="click"
|
||||||
|
:confirm-btn="{ content: '插入', theme: 'primary' }"
|
||||||
|
:cancel-btn="{ content: '取消', theme: 'default' }"
|
||||||
|
placement="bottom"
|
||||||
|
@visible-change="onVisibleChange"
|
||||||
|
@confirm="onConfirm"
|
||||||
|
>
|
||||||
|
<template #icon><span></span></template>
|
||||||
|
<template #content>
|
||||||
|
<t-form class="popconfirm-content" label-align="left" label-width="auto">
|
||||||
|
<t-form-item label="网页地址">
|
||||||
|
<t-input v-model="iframeForm.src" placeholder="请输入网页地址" />
|
||||||
|
</t-form-item>
|
||||||
|
<t-form-item label="网页标题">
|
||||||
|
<t-input v-model="iframeForm.title" placeholder="请输入网页标题,可留空" />
|
||||||
|
</t-form-item>
|
||||||
|
<t-form-item label="宽度">
|
||||||
|
<t-input v-model="iframeForm.width" placeholder="请输入宽度,默认 100%" />
|
||||||
|
</t-form-item>
|
||||||
|
<t-form-item label="高度">
|
||||||
|
<t-input v-model="iframeForm.height" placeholder="请输入高度,默认 28rem" />
|
||||||
|
</t-form-item>
|
||||||
|
</t-form>
|
||||||
|
</template>
|
||||||
|
<t-button v-popup="'插入内嵌网页'" variant="outline" :theme="active ? 'primary' : 'default'">
|
||||||
|
<t-icon name="web" />
|
||||||
|
</t-button>
|
||||||
|
</t-popconfirm>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { showTDesignMessage } from "../../utils/TDesignMessage";
|
||||||
|
import type { TiptapEditorInstance } from "./useTiptapEditor";
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: "TiptapIframeButton"
|
||||||
|
});
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
editor?: TiptapEditorInstance;
|
||||||
|
active: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const appContext = getCurrentInstance()?.appContext;
|
||||||
|
const visible = ref(false);
|
||||||
|
const iframeForm = reactive({
|
||||||
|
src: "",
|
||||||
|
title: "",
|
||||||
|
width: "100%",
|
||||||
|
height: "28rem"
|
||||||
|
});
|
||||||
|
|
||||||
|
function onVisibleChange(nextVisible: boolean) {
|
||||||
|
if (!nextVisible || !props.editor) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const attributes = props.editor.getAttributes("iframe");
|
||||||
|
iframeForm.src = (attributes.src as string) || "https://";
|
||||||
|
iframeForm.title = (attributes.title as string) || "";
|
||||||
|
iframeForm.width = (attributes.width as string) || "100%";
|
||||||
|
iframeForm.height = (attributes.height as string) || "28rem";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidHttpUrl(value: string) {
|
||||||
|
try {
|
||||||
|
const url = new URL(value);
|
||||||
|
return url.protocol === "http:" || url.protocol === "https:";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onConfirm() {
|
||||||
|
if (!props.editor) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const src = iframeForm.src.trim();
|
||||||
|
if (!src) {
|
||||||
|
visible.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isValidHttpUrl(src)) {
|
||||||
|
await showTDesignMessage(appContext, "warning", "请输入有效的 http 或 https 地址");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
props.editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.insertContent({
|
||||||
|
type: "iframe",
|
||||||
|
attrs: {
|
||||||
|
src,
|
||||||
|
title: iframeForm.title.trim() || "内嵌网页",
|
||||||
|
width: iframeForm.width.trim() || "100%",
|
||||||
|
height: iframeForm.height.trim() || "28rem"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
visible.value = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.popconfirm-content {
|
||||||
|
--td-comp-margin-xxl: .5rem;
|
||||||
|
|
||||||
|
padding: 0 .5rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
<template>
|
||||||
|
<t-popconfirm
|
||||||
|
v-model:visible="visible"
|
||||||
|
trigger="click"
|
||||||
|
:confirm-btn="{ content: '插入', theme: 'primary' }"
|
||||||
|
:cancel-btn="{ content: '取消', theme: 'default' }"
|
||||||
|
placement="bottom"
|
||||||
|
@visible-change="onVisibleChange"
|
||||||
|
@confirm="onConfirm"
|
||||||
|
>
|
||||||
|
<template #icon><span></span></template>
|
||||||
|
<template #content>
|
||||||
|
<div class="link-form">
|
||||||
|
<t-input v-model="linkForm.href" placeholder="请输入链接地址" />
|
||||||
|
<t-input v-model="linkForm.text" placeholder="请输入显示文本,可留空" />
|
||||||
|
<t-checkbox v-model="linkForm.syncToHref">显示文本同步为链接</t-checkbox>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<t-button v-popup="'插入链接'" variant="outline" :theme="active ? 'primary' : 'default'">
|
||||||
|
<t-icon name="link-1" />
|
||||||
|
</t-button>
|
||||||
|
</t-popconfirm>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { TiptapEditorInstance } from "./useTiptapEditor";
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: "TiptapLinkButton"
|
||||||
|
});
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
editor?: TiptapEditorInstance;
|
||||||
|
active: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const visible = ref(false);
|
||||||
|
const linkForm = reactive({
|
||||||
|
href: "",
|
||||||
|
text: "",
|
||||||
|
syncToHref: false
|
||||||
|
});
|
||||||
|
|
||||||
|
function onVisibleChange(nextVisible: boolean) {
|
||||||
|
if (!nextVisible || !props.editor) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const previousUrl = (props.editor.getAttributes("link").href as string) || "";
|
||||||
|
const selectedText = props.editor.state.doc.textBetween(
|
||||||
|
props.editor.state.selection.from,
|
||||||
|
props.editor.state.selection.to,
|
||||||
|
""
|
||||||
|
);
|
||||||
|
linkForm.href = previousUrl || "https://";
|
||||||
|
linkForm.text = selectedText || "";
|
||||||
|
linkForm.syncToHref = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onConfirm() {
|
||||||
|
if (!props.editor) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const href = linkForm.href.trim();
|
||||||
|
if (!href) {
|
||||||
|
props.editor.chain().focus().unsetLink().run();
|
||||||
|
visible.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const text = linkForm.syncToHref ? href : linkForm.text.trim();
|
||||||
|
if (text) {
|
||||||
|
props.editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.insertContent({
|
||||||
|
type: "text",
|
||||||
|
text,
|
||||||
|
marks: [{ type: "link", attrs: { href } }]
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
visible.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
props.editor.chain().focus().extendMarkRange("link").setLink({ href }).run();
|
||||||
|
visible.value = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.link-form {
|
||||||
|
gap: .75rem;
|
||||||
|
width: 20rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
<template>
|
||||||
|
<t-popconfirm
|
||||||
|
class="tiptap-media-button"
|
||||||
|
v-model:visible="visible"
|
||||||
|
trigger="click"
|
||||||
|
:confirm-btn="{ content: '插入', theme: 'primary' }"
|
||||||
|
:cancel-btn="{ content: '取消', theme: 'default' }"
|
||||||
|
placement="bottom"
|
||||||
|
@visible-change="onVisibleChange"
|
||||||
|
@confirm="onConfirm"
|
||||||
|
>
|
||||||
|
<template #icon><span></span></template>
|
||||||
|
<template #content>
|
||||||
|
<t-form class="popconfirm-content" label-align="left" label-width="auto">
|
||||||
|
<t-form-item label="文件">
|
||||||
|
<t-button size="small" variant="outline" @click="pickFile">
|
||||||
|
<span
|
||||||
|
class="file-name keep-text"
|
||||||
|
v-text="selectedFile ? '重新选择' : `选择${typeLabel}`"
|
||||||
|
/>
|
||||||
|
</t-button>
|
||||||
|
<span class="word-space gray" v-text="selectedFile?.name || `未选择${typeLabel}文件`" />
|
||||||
|
</t-form-item>
|
||||||
|
<t-form-item label="标题">
|
||||||
|
<t-input v-model="mediaForm.title" :placeholder="`请输入${typeLabel}标题,可留空`" />
|
||||||
|
</t-form-item>
|
||||||
|
<t-form-item label="宽度">
|
||||||
|
<t-input v-model="mediaForm.width" placeholder="请输入宽度,默认 100%" />
|
||||||
|
</t-form-item>
|
||||||
|
<t-form-item label="高度">
|
||||||
|
<t-input v-model="mediaForm.height" placeholder="请输入高度,默认 auto" />
|
||||||
|
</t-form-item>
|
||||||
|
</t-form>
|
||||||
|
</template>
|
||||||
|
<t-button variant="outline" :theme="active ? 'primary' : 'default'" :loading="uploading">
|
||||||
|
<t-icon :name="iconName" />
|
||||||
|
</t-button>
|
||||||
|
</t-popconfirm>
|
||||||
|
<input ref="fileInputRef" class="file-input" type="file" :accept="accept" @change="onFileChange" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { showTDesignMessage } from "../../utils/TDesignMessage";
|
||||||
|
import type { TiptapEditorInstance } from "./useTiptapEditor";
|
||||||
|
import type { UploadResult } from "./shared";
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: "TiptapMediaButton"
|
||||||
|
});
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
editor?: TiptapEditorInstance;
|
||||||
|
active: boolean;
|
||||||
|
type: "image" | "video";
|
||||||
|
upload?: (file: File) => Promise<UploadResult>;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const appContext = getCurrentInstance()?.appContext;
|
||||||
|
const visible = ref(false);
|
||||||
|
const uploading = ref(false);
|
||||||
|
const selectedFile = ref<File>();
|
||||||
|
const fileInputRef = ref<HTMLInputElement>();
|
||||||
|
const mediaForm = reactive({
|
||||||
|
title: "",
|
||||||
|
width: "100%",
|
||||||
|
height: ""
|
||||||
|
});
|
||||||
|
|
||||||
|
const typeLabel = computed(() => {
|
||||||
|
return props.type === "image" ? "图片" : "视频";
|
||||||
|
});
|
||||||
|
|
||||||
|
const iconName = computed(() => {
|
||||||
|
return props.type === "image" ? "image-add" : "video";
|
||||||
|
});
|
||||||
|
|
||||||
|
const accept = computed(() => {
|
||||||
|
return props.type === "image" ? "image/*" : "video/*";
|
||||||
|
});
|
||||||
|
|
||||||
|
function buildDefaultHeight() {
|
||||||
|
return "auto";
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickFile(event?: MouseEvent) {
|
||||||
|
event?.stopPropagation();
|
||||||
|
fileInputRef.value?.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onVisibleChange(nextVisible: boolean) {
|
||||||
|
if (!nextVisible || !props.editor) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
selectedFile.value = undefined;
|
||||||
|
const attributes = props.editor.getAttributes(props.type);
|
||||||
|
mediaForm.title = (attributes.title as string) || "";
|
||||||
|
mediaForm.width = (attributes.width as string) || "100%";
|
||||||
|
mediaForm.height = (attributes.height as string) || buildDefaultHeight();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFileChange(event: Event) {
|
||||||
|
const target = event.target as HTMLInputElement;
|
||||||
|
selectedFile.value = target.files?.[0];
|
||||||
|
target.value = "";
|
||||||
|
if (!selectedFile.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!mediaForm.title.trim()) {
|
||||||
|
mediaForm.title = selectedFile.value.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onConfirm() {
|
||||||
|
if (!props.editor) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!props.upload) {
|
||||||
|
await showTDesignMessage(appContext, "warning", `${typeLabel.value}上传功能未启用`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!selectedFile.value) {
|
||||||
|
await showTDesignMessage(appContext, "warning", `请选择${typeLabel.value}文件`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
uploading.value = true;
|
||||||
|
try {
|
||||||
|
const file = selectedFile.value;
|
||||||
|
const result = await props.upload(file);
|
||||||
|
const title = mediaForm.title.trim() || result.name || file.name;
|
||||||
|
const attrs = {
|
||||||
|
src: result.url,
|
||||||
|
title,
|
||||||
|
width: mediaForm.width.trim() || "100%",
|
||||||
|
height: mediaForm.height.trim() || buildDefaultHeight(),
|
||||||
|
attachmentId: result.id,
|
||||||
|
tempFileId: result.id
|
||||||
|
};
|
||||||
|
if (props.type === "image") {
|
||||||
|
props.editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.setImage({
|
||||||
|
src: attrs.src,
|
||||||
|
alt: title,
|
||||||
|
title
|
||||||
|
})
|
||||||
|
.updateAttributes("image", {
|
||||||
|
attachmentId: attrs.attachmentId,
|
||||||
|
tempFileId: attrs.tempFileId,
|
||||||
|
width: attrs.width,
|
||||||
|
height: attrs.height
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
} else {
|
||||||
|
props.editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.insertContent({
|
||||||
|
type: "video",
|
||||||
|
attrs
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
visible.value = false;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`上传${typeLabel.value}失败:`, error);
|
||||||
|
await showTDesignMessage(appContext, "error", `上传${typeLabel.value}失败`);
|
||||||
|
} finally {
|
||||||
|
uploading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.popconfirm-content {
|
||||||
|
--td-comp-margin-xxl: .5rem;
|
||||||
|
|
||||||
|
padding: 0 .5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
<template>
|
||||||
|
<t-popconfirm
|
||||||
|
v-model:visible="visible"
|
||||||
|
trigger="click"
|
||||||
|
placement="bottom-right"
|
||||||
|
:show-arrow="false"
|
||||||
|
:confirm-btn="null"
|
||||||
|
:cancel-btn="null"
|
||||||
|
:popup-props="{ overlayClassName: 'tp-padding-pop' }"
|
||||||
|
@visible-change="onVisibleChange"
|
||||||
|
>
|
||||||
|
<template #icon><span></span></template>
|
||||||
|
<template #content>
|
||||||
|
<div class="padding-form">
|
||||||
|
<div class="item">
|
||||||
|
<div class="head">
|
||||||
|
<span class="label">上下内边距</span>
|
||||||
|
<span class="value" v-text="formatPaddingValue(paddingTB)" />
|
||||||
|
</div>
|
||||||
|
<t-slider :min="0" :max="6" :step=".25" :value="paddingTB" @change="onPaddingTBChange" />
|
||||||
|
</div>
|
||||||
|
<div class="item">
|
||||||
|
<div class="head">
|
||||||
|
<span class="label">左右内边距</span>
|
||||||
|
<span class="value" v-text="formatPaddingValue(paddingLR)" />
|
||||||
|
</div>
|
||||||
|
<t-slider :min="0" :max="16" :step=".25" :value="paddingLR" @change="onPaddingLRChange" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<t-button v-popup="'内边距'" variant="outline">
|
||||||
|
<t-icon name="fullscreen-exit-1" />
|
||||||
|
</t-button>
|
||||||
|
</t-popconfirm>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { formatPaddingValue, normalizeSliderValue } from "./shared";
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: "TiptapPaddingButton"
|
||||||
|
});
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
paddingTB: number;
|
||||||
|
paddingLR: number;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
"update:paddingTB": [value: number];
|
||||||
|
"update:paddingLR": [value: number];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const visible = ref(false);
|
||||||
|
|
||||||
|
function onVisibleChange(nextVisible: boolean) {
|
||||||
|
if (nextVisible) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPaddingTBChange(value: number | number[]) {
|
||||||
|
emit("update:paddingTB", normalizeSliderValue(value, props.paddingTB));
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPaddingLRChange(value: number | number[]) {
|
||||||
|
emit("update:paddingLR", normalizeSliderValue(value, props.paddingLR));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.padding-form {
|
||||||
|
gap: .75rem;
|
||||||
|
width: 18rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.item {
|
||||||
|
gap: .5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
.label {
|
||||||
|
color: var(--td-text-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
color: var(--td-text-color-secondary);
|
||||||
|
font-size: .75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
<template>
|
||||||
|
<t-popconfirm
|
||||||
|
v-model:visible="visible"
|
||||||
|
trigger="click"
|
||||||
|
placement="bottom"
|
||||||
|
:show-arrow="false"
|
||||||
|
:popup-props="{ overlayClassName: 'tp-table-pop' }"
|
||||||
|
@visible-change="onVisibleChange"
|
||||||
|
>
|
||||||
|
<template #icon><span></span></template>
|
||||||
|
<template #content>
|
||||||
|
<div class="table-picker" @mouseleave="onReset">
|
||||||
|
<div class="tip" v-text="`${hoverRow} × ${hoverCol}`" />
|
||||||
|
<div class="grid">
|
||||||
|
<button
|
||||||
|
v-for="item in tablePickerItems"
|
||||||
|
:key="item.key"
|
||||||
|
class="cell"
|
||||||
|
:class="{ active: item.active }"
|
||||||
|
type="button"
|
||||||
|
@mousemove="onHover(item.row, item.col)"
|
||||||
|
@click="onSelect(item.row, item.col)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<t-button v-popup="'插入表格'" variant="outline" :theme="active ? 'primary' : 'default'">
|
||||||
|
<t-icon name="table" />
|
||||||
|
</t-button>
|
||||||
|
</t-popconfirm>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { TABLE_PICKER_SIZE } from "./shared";
|
||||||
|
import type { TiptapEditorInstance } from "./useTiptapEditor";
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: "TiptapTableButton"
|
||||||
|
});
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
editor?: TiptapEditorInstance;
|
||||||
|
active: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const visible = ref(false);
|
||||||
|
const hoverRow = ref(1);
|
||||||
|
const hoverCol = ref(1);
|
||||||
|
|
||||||
|
const tablePickerItems = computed(() => {
|
||||||
|
const items: { key: string; row: number; col: number; active: boolean }[] = [];
|
||||||
|
for (let row = 1; row < TABLE_PICKER_SIZE + 1; row += 1) {
|
||||||
|
for (let col = 1; col < TABLE_PICKER_SIZE + 1; col += 1) {
|
||||||
|
items.push({
|
||||||
|
key: `${row}-${col}`,
|
||||||
|
row,
|
||||||
|
col,
|
||||||
|
active: row < hoverRow.value + 1 && col < hoverCol.value + 1
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
});
|
||||||
|
|
||||||
|
function onVisibleChange(nextVisible: boolean) {
|
||||||
|
if (!nextVisible) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
hoverRow.value = 1;
|
||||||
|
hoverCol.value = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onReset() {
|
||||||
|
hoverRow.value = 1;
|
||||||
|
hoverCol.value = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onHover(row: number, col: number) {
|
||||||
|
hoverRow.value = row;
|
||||||
|
hoverCol.value = col;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSelect(row: number, col: number) {
|
||||||
|
if (!props.editor) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
hoverRow.value = row;
|
||||||
|
hoverCol.value = col;
|
||||||
|
props.editor.chain().focus().insertTable({ rows: row, cols: col, withHeaderRow: true }).run();
|
||||||
|
visible.value = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.table-picker {
|
||||||
|
width: 10.5rem;
|
||||||
|
|
||||||
|
.tip {
|
||||||
|
height: 1.5rem;
|
||||||
|
color: var(--td-text-color-secondary);
|
||||||
|
font-size: .75rem;
|
||||||
|
line-height: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
gap: .125rem;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell {
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--td-component-border);
|
||||||
|
cursor: pointer;
|
||||||
|
background: var(--td-bg-color-container);
|
||||||
|
transition: background .15s ease, border-color .15s ease;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
border-color: var(--td-brand-color);
|
||||||
|
background: var(--td-brand-color-light);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,431 @@
|
|||||||
|
<template>
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="left" :class="{ 'toolbar-disabled': sourceCodeMode }">
|
||||||
|
<div class="fixed">
|
||||||
|
<t-button v-popup="'撤销'" variant="outline" :disabled="!canExec('undo')" @click="run('undo')">
|
||||||
|
<t-icon name="rollback" />
|
||||||
|
</t-button>
|
||||||
|
<t-button v-popup="'重做'" variant="outline" :disabled="!canExec('redo')" @click="run('redo')">
|
||||||
|
<t-icon name="rollfront" />
|
||||||
|
</t-button>
|
||||||
|
<t-button v-popup="'清除格式'" variant="outline" @click="run('clear')">
|
||||||
|
<t-icon name="clear-formatting" />
|
||||||
|
</t-button>
|
||||||
|
</div>
|
||||||
|
<div class="scroll">
|
||||||
|
<t-dropdown trigger="click" :options="headingOptions" :min-column-width="120" @click="onHeadingSelect">
|
||||||
|
<t-button v-popup="'标题'" variant="outline" :theme="isHeadingActive ? 'primary' : 'default'">
|
||||||
|
<t-icon name="text" />
|
||||||
|
</t-button>
|
||||||
|
</t-dropdown>
|
||||||
|
<t-button v-popup="'加粗'" variant="outline" :theme="isActive('bold') ? 'primary' : 'default'" @click="run('bold')">
|
||||||
|
<t-icon name="textformat-bold" />
|
||||||
|
</t-button>
|
||||||
|
<t-button v-popup="'斜体'" variant="outline" :theme="isActive('italic') ? 'primary' : 'default'" @click="run('italic')">
|
||||||
|
<t-icon name="textformat-italic" />
|
||||||
|
</t-button>
|
||||||
|
<t-button v-popup="'下划线'" variant="outline" :theme="isActive('underline') ? 'primary' : 'default'" @click="run('underline')">
|
||||||
|
<t-icon name="textformat-underline" />
|
||||||
|
</t-button>
|
||||||
|
<t-button v-popup="'删除线'" variant="outline" :theme="isActive('strike') ? 'primary' : 'default'" @click="run('strike')">
|
||||||
|
<t-icon name="textformat-strikethrough" />
|
||||||
|
</t-button>
|
||||||
|
<t-button v-popup="'前景色'" variant="outline" @click="pickColor('text')">
|
||||||
|
<t-icon name="textformat-color" />
|
||||||
|
</t-button>
|
||||||
|
<t-button v-popup="'背景色'" variant="outline" @click="pickColor('bg')">
|
||||||
|
<t-icon name="fill-color-1" />
|
||||||
|
</t-button>
|
||||||
|
<t-button v-popup="'有序列表'" variant="outline" :theme="isActive('orderedList') ? 'primary' : 'default'" @click="run('orderedList')">
|
||||||
|
<t-icon name="order-list" />
|
||||||
|
</t-button>
|
||||||
|
<tiptap-table-button :editor="editor" :active="isActive('table')" />
|
||||||
|
<t-button v-popup="'无序列表'" variant="outline" :theme="isActive('bulletList') ? 'primary' : 'default'" @click="run('bulletList')">
|
||||||
|
<t-icon name="bulletpoint" />
|
||||||
|
</t-button>
|
||||||
|
<t-button v-popup="'行内代码'" variant="outline" :theme="isActive('code') ? 'primary' : 'default'" @click="run('code')">
|
||||||
|
<t-icon name="highlighted-block" />
|
||||||
|
</t-button>
|
||||||
|
<tiptap-code-block-button :editor="editor" :active="isActive('codeBlock')" />
|
||||||
|
<t-button v-popup="'下标'" variant="outline" :theme="isActive('subscript') ? 'primary' : 'default'" @click="run('subscript')">
|
||||||
|
<t-icon name="subscript" />
|
||||||
|
</t-button>
|
||||||
|
<t-button v-popup="'上标'" variant="outline" :theme="isActive('superscript') ? 'primary' : 'default'" @click="run('superscript')">
|
||||||
|
<t-icon name="superscript" />
|
||||||
|
</t-button>
|
||||||
|
<t-button v-popup="'左对齐'" variant="outline" :theme="isAlignActive('left') ? 'primary' : 'default'" @click="setAlign('left')">
|
||||||
|
<t-icon name="format-vertical-align-left" />
|
||||||
|
</t-button>
|
||||||
|
<t-button v-popup="'居中对齐'" variant="outline" :theme="isAlignActive('center') ? 'primary' : 'default'" @click="setAlign('center')">
|
||||||
|
<t-icon name="format-vertical-align-center" />
|
||||||
|
</t-button>
|
||||||
|
<t-button v-popup="'右对齐'" variant="outline" :theme="isAlignActive('right') ? 'primary' : 'default'" @click="setAlign('right')">
|
||||||
|
<t-icon name="format-vertical-align-right" />
|
||||||
|
</t-button>
|
||||||
|
<t-button v-popup="'两端对齐'" variant="outline" :theme="isAlignActive('justify') ? 'primary' : 'default'" @click="setAlign('justify')">
|
||||||
|
<t-icon name="expand-horizontal" />
|
||||||
|
</t-button>
|
||||||
|
<t-button v-popup="'分割线'" variant="outline" @click="run('horizontalRule')">
|
||||||
|
<t-icon name="divider-1" />
|
||||||
|
</t-button>
|
||||||
|
<tiptap-link-button :editor="editor" :active="isActive('link')" />
|
||||||
|
<tiptap-media-button :editor="editor" :active="isActive('image')" type="image" :upload="uploadImage" />
|
||||||
|
<tiptap-media-button :editor="editor" :active="isActive('video')" type="video" :upload="uploadVideo" />
|
||||||
|
<tiptap-iframe-button :editor="editor" :active="isActive('iframe')" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="right">
|
||||||
|
<div v-if="showPaddingTool" class="right__group" :class="{ 'toolbar-disabled': sourceCodeMode }">
|
||||||
|
<tiptap-padding-button
|
||||||
|
:padding-t-b="paddingTB"
|
||||||
|
:padding-l-r="paddingLR"
|
||||||
|
@update:padding-t-b="emit('update:paddingTB', $event)"
|
||||||
|
@update:padding-l-r="emit('update:paddingLR', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<t-button
|
||||||
|
v-popup="sourceCodeMode ? '富文本' : '源码'"
|
||||||
|
class="source-btn"
|
||||||
|
variant="outline"
|
||||||
|
:theme="sourceCodeMode ? 'primary' : 'default'"
|
||||||
|
@click="emit('toggle-source-code')"
|
||||||
|
>
|
||||||
|
<t-icon v-if="sourceCodeMode" name="edit-1" />
|
||||||
|
<t-icon v-else name="braces" />
|
||||||
|
</t-button>
|
||||||
|
</div>
|
||||||
|
<input ref="textColorRef" class="hidden" type="color" :value="textColor" @input="onTextColorChange" />
|
||||||
|
<input ref="bgColorRef" class="hidden" type="color" :value="bgColor" @input="onBgColorChange" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { AlignType, UploadResult } from "./shared";
|
||||||
|
import { buildHeadingOptions } from "./shared";
|
||||||
|
import type { TiptapEditorInstance } from "./useTiptapEditor";
|
||||||
|
import TiptapCodeBlockButton from "./TiptapCodeBlockButton.vue";
|
||||||
|
import TiptapIframeButton from "./TiptapIframeButton.vue";
|
||||||
|
import TiptapLinkButton from "./TiptapLinkButton.vue";
|
||||||
|
import TiptapMediaButton from "./TiptapMediaButton.vue";
|
||||||
|
import TiptapPaddingButton from "./TiptapPaddingButton.vue";
|
||||||
|
import TiptapTableButton from "./TiptapTableButton.vue";
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: "TiptapToolbar"
|
||||||
|
});
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
editor?: TiptapEditorInstance;
|
||||||
|
uploadImage?: (file: File) => Promise<UploadResult>;
|
||||||
|
uploadVideo?: (file: File) => Promise<UploadResult>;
|
||||||
|
paddingTB: number;
|
||||||
|
paddingLR: number;
|
||||||
|
showPaddingTool?: boolean;
|
||||||
|
sourceCodeMode?: boolean;
|
||||||
|
}>(), {
|
||||||
|
showPaddingTool: true,
|
||||||
|
sourceCodeMode: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
"update:paddingTB": [value: number];
|
||||||
|
"update:paddingLR": [value: number];
|
||||||
|
"toggle-source-code": [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
type RunType =
|
||||||
|
| "undo"
|
||||||
|
| "redo"
|
||||||
|
| "bold"
|
||||||
|
| "italic"
|
||||||
|
| "underline"
|
||||||
|
| "strike"
|
||||||
|
| "orderedList"
|
||||||
|
| "bulletList"
|
||||||
|
| "code"
|
||||||
|
| "subscript"
|
||||||
|
| "superscript"
|
||||||
|
| "horizontalRule"
|
||||||
|
| "clear";
|
||||||
|
|
||||||
|
const textColorRef = ref<HTMLInputElement>();
|
||||||
|
const bgColorRef = ref<HTMLInputElement>();
|
||||||
|
const textColor = ref("#000000");
|
||||||
|
const bgColor = ref("#ffff00");
|
||||||
|
|
||||||
|
const currentHeadingValue = computed(() => {
|
||||||
|
if (!props.editor) {
|
||||||
|
return "p";
|
||||||
|
}
|
||||||
|
for (let level = 1; level < 7; level += 1) {
|
||||||
|
if (props.editor.isActive("heading", { level })) {
|
||||||
|
return `h${level}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "p";
|
||||||
|
});
|
||||||
|
|
||||||
|
const headingOptions = computed(() => {
|
||||||
|
return buildHeadingOptions(currentHeadingValue.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
const isHeadingActive = computed(() => {
|
||||||
|
return currentHeadingValue.value !== "p";
|
||||||
|
});
|
||||||
|
|
||||||
|
function canExec(type: "undo" | "redo") {
|
||||||
|
if (!props.editor) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
switch (type) {
|
||||||
|
case "undo":
|
||||||
|
return props.editor.can().undo();
|
||||||
|
case "redo":
|
||||||
|
return props.editor.can().redo();
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isActive(type: string) {
|
||||||
|
if (!props.editor) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return props.editor.isActive(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAlignActive(align: AlignType) {
|
||||||
|
if (!props.editor) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return props.editor.isActive({ textAlign: align });
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(type: RunType) {
|
||||||
|
if (!props.editor) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const chain = props.editor.chain().focus();
|
||||||
|
switch (type) {
|
||||||
|
case "undo":
|
||||||
|
chain.undo().run();
|
||||||
|
return;
|
||||||
|
case "redo":
|
||||||
|
chain.redo().run();
|
||||||
|
return;
|
||||||
|
case "bold":
|
||||||
|
chain.toggleBold().run();
|
||||||
|
return;
|
||||||
|
case "italic":
|
||||||
|
chain.toggleItalic().run();
|
||||||
|
return;
|
||||||
|
case "underline":
|
||||||
|
chain.toggleUnderline().run();
|
||||||
|
return;
|
||||||
|
case "strike":
|
||||||
|
chain.toggleStrike().run();
|
||||||
|
return;
|
||||||
|
case "orderedList":
|
||||||
|
chain.toggleOrderedList().run();
|
||||||
|
return;
|
||||||
|
case "bulletList":
|
||||||
|
chain.toggleBulletList().run();
|
||||||
|
return;
|
||||||
|
case "code":
|
||||||
|
chain.toggleCode().run();
|
||||||
|
return;
|
||||||
|
case "subscript":
|
||||||
|
chain.toggleSubscript().run();
|
||||||
|
return;
|
||||||
|
case "superscript":
|
||||||
|
chain.toggleSuperscript().run();
|
||||||
|
return;
|
||||||
|
case "horizontalRule":
|
||||||
|
chain.setHorizontalRule().run();
|
||||||
|
return;
|
||||||
|
case "clear":
|
||||||
|
chain.clearNodes().unsetAllMarks().run();
|
||||||
|
return;
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setAlign(align: AlignType) {
|
||||||
|
props.editor?.chain().focus().setTextAlign(align).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onHeadingSelect(option: { value?: string | number | Record<string, unknown> }) {
|
||||||
|
if (!props.editor) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const value = `${option?.value || "p"}`;
|
||||||
|
const chain = props.editor.chain().focus();
|
||||||
|
if (value === "p") {
|
||||||
|
chain.setParagraph().run();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const level = Number(value.replace("h", ""));
|
||||||
|
if (0 < level && level < 7) {
|
||||||
|
chain.setHeading({ level: level as 1 | 2 | 3 | 4 | 5 | 6 }).run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickColor(type: "text" | "bg") {
|
||||||
|
if (type === "text") {
|
||||||
|
textColorRef.value?.click();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
bgColorRef.value?.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTextColorChange(event: Event) {
|
||||||
|
if (!props.editor) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const target = event.target as HTMLInputElement;
|
||||||
|
textColor.value = target.value;
|
||||||
|
props.editor.chain().focus().setColor(target.value).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBgColorChange(event: Event) {
|
||||||
|
if (!props.editor) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const target = event.target as HTMLInputElement;
|
||||||
|
bgColor.value = target.value;
|
||||||
|
props.editor.chain().focus().setMark("textStyle", { backgroundColor: target.value }).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
position: relative;
|
||||||
|
margin-top: -1px;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
&:after {
|
||||||
|
content: "";
|
||||||
|
width: 100%;
|
||||||
|
height: 1px;
|
||||||
|
bottom: 0;
|
||||||
|
position: absolute;
|
||||||
|
background: var(--td-component-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left,
|
||||||
|
.right {
|
||||||
|
gap: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
|
||||||
|
:deep(.t-button) {
|
||||||
|
z-index: 1;
|
||||||
|
position: relative;
|
||||||
|
margin-left: -1px;
|
||||||
|
border-radius: 0;
|
||||||
|
--td-comp-paddingLR-l: 8px;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:focus-visible,
|
||||||
|
&.t-is-active,
|
||||||
|
&.t-button--theme-primary {
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.left {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
.fixed,
|
||||||
|
.scroll {
|
||||||
|
gap: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fixed {
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
> * {
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.t-button:last-child) {
|
||||||
|
margin-right: 3rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.right {
|
||||||
|
flex: none;
|
||||||
|
margin-right: -1px;
|
||||||
|
|
||||||
|
.right__group {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-disabled {
|
||||||
|
opacity: .55;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-btn {
|
||||||
|
margin-left: -1px;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 48rem) {
|
||||||
|
.toolbar {
|
||||||
|
justify-content: flex-start;
|
||||||
|
|
||||||
|
.left {
|
||||||
|
.scroll {
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
scrollbar-width: none;
|
||||||
|
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.tp-heading-option) {
|
||||||
|
display: block;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heading-size(@name, @size, @weight) {
|
||||||
|
:global(.tp-heading-option.@{name}) {
|
||||||
|
font-size: @size;
|
||||||
|
font-weight: @weight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.heading-size(p, 1rem, 400);
|
||||||
|
.heading-size(h1, 2rem, 700);
|
||||||
|
.heading-size(h2, 1.5rem, 700);
|
||||||
|
.heading-size(h3, 1.17rem, 700);
|
||||||
|
.heading-size(h4, 1rem, 700);
|
||||||
|
.heading-size(h5, .83rem, 700);
|
||||||
|
.heading-size(h6, .67rem, 700);
|
||||||
|
</style>
|
||||||
|
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
import { mergeAttributes, Node } from "@tiptap/vue-3";
|
||||||
|
import Color from "@tiptap/extension-color";
|
||||||
|
import Image from "@tiptap/extension-image";
|
||||||
|
import Link from "@tiptap/extension-link";
|
||||||
|
import Placeholder from "@tiptap/extension-placeholder";
|
||||||
|
import Subscript from "@tiptap/extension-subscript";
|
||||||
|
import Superscript from "@tiptap/extension-superscript";
|
||||||
|
import { Table } from "@tiptap/extension-table";
|
||||||
|
import TableCell from "@tiptap/extension-table-cell";
|
||||||
|
import TableHeader from "@tiptap/extension-table-header";
|
||||||
|
import TableRow from "@tiptap/extension-table-row";
|
||||||
|
import TextAlign from "@tiptap/extension-text-align";
|
||||||
|
import { TextStyle } from "@tiptap/extension-text-style";
|
||||||
|
import Underline from "@tiptap/extension-underline";
|
||||||
|
import type { Extensions } from "@tiptap/vue-3";
|
||||||
|
import StarterKit from "@tiptap/starter-kit";
|
||||||
|
import PrismCodeBlock from "./PrismCodeBlock";
|
||||||
|
import { ATTACHMENT_ID_ATTR, TEMP_FILE_ID_ATTR } from "./shared";
|
||||||
|
|
||||||
|
function buildStyleString(styleList: Array<string | null | undefined>) {
|
||||||
|
return styleList.filter(Boolean).join("; ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildNodeSizeAttributes(defaultHeight = "auto") {
|
||||||
|
return {
|
||||||
|
width: {
|
||||||
|
default: "100%",
|
||||||
|
parseHTML: (element: HTMLElement) => element.style.width || "100%"
|
||||||
|
},
|
||||||
|
height: {
|
||||||
|
default: defaultHeight,
|
||||||
|
parseHTML: (element: HTMLElement) => element.style.height || defaultHeight
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildNodeSizeStyle(attributes: Record<string, string>) {
|
||||||
|
return buildStyleString([
|
||||||
|
attributes.style,
|
||||||
|
attributes.width ? `width: ${attributes.width}` : null,
|
||||||
|
attributes.height ? `height: ${attributes.height}` : null
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ExtTextStyle = TextStyle.extend({
|
||||||
|
addGlobalAttributes() {
|
||||||
|
return [
|
||||||
|
...(this.parent?.() || []),
|
||||||
|
{
|
||||||
|
types: ["textStyle"],
|
||||||
|
attributes: {
|
||||||
|
backgroundColor: {
|
||||||
|
default: null,
|
||||||
|
parseHTML: (element: HTMLElement) => element.style.backgroundColor || null,
|
||||||
|
renderHTML: (attributes: Record<string, string>) => {
|
||||||
|
if (!attributes.backgroundColor) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
style: `background-color: ${attributes.backgroundColor}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const ExtImage = Image.configure({
|
||||||
|
allowBase64: true
|
||||||
|
}).extend({
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
...this.parent?.(),
|
||||||
|
...buildNodeSizeAttributes(),
|
||||||
|
attachmentId: {
|
||||||
|
default: null,
|
||||||
|
parseHTML: (element: HTMLElement) => element.getAttribute(ATTACHMENT_ID_ATTR),
|
||||||
|
renderHTML: (attributes: Record<string, string>) => {
|
||||||
|
if (!attributes.attachmentId) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
[ATTACHMENT_ID_ATTR]: attributes.attachmentId
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tempFileId: {
|
||||||
|
default: null,
|
||||||
|
parseHTML: (element: HTMLElement) => element.getAttribute(TEMP_FILE_ID_ATTR),
|
||||||
|
renderHTML: (attributes: Record<string, string>) => {
|
||||||
|
if (!attributes.tempFileId) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
[TEMP_FILE_ID_ATTR]: attributes.tempFileId
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, string> }) {
|
||||||
|
return [
|
||||||
|
"img",
|
||||||
|
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
|
||||||
|
style: buildNodeSizeStyle(HTMLAttributes)
|
||||||
|
})
|
||||||
|
];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const ExtVideo = Node.create({
|
||||||
|
name: "video",
|
||||||
|
group: "block",
|
||||||
|
atom: true,
|
||||||
|
draggable: true,
|
||||||
|
selectable: true,
|
||||||
|
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
src: {
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
...buildNodeSizeAttributes(),
|
||||||
|
attachmentId: {
|
||||||
|
default: null,
|
||||||
|
parseHTML: (element: HTMLElement) => element.getAttribute(ATTACHMENT_ID_ATTR),
|
||||||
|
renderHTML: (attributes: Record<string, string>) => {
|
||||||
|
if (!attributes.attachmentId) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
[ATTACHMENT_ID_ATTR]: attributes.attachmentId
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tempFileId: {
|
||||||
|
default: null,
|
||||||
|
parseHTML: (element: HTMLElement) => element.getAttribute(TEMP_FILE_ID_ATTR),
|
||||||
|
renderHTML: (attributes: Record<string, string>) => {
|
||||||
|
if (!attributes.tempFileId) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
[TEMP_FILE_ID_ATTR]: attributes.tempFileId
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
parseHTML() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
tag: "video"
|
||||||
|
}
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, string> }) {
|
||||||
|
return [
|
||||||
|
"video",
|
||||||
|
mergeAttributes(
|
||||||
|
{
|
||||||
|
controls: "true",
|
||||||
|
style: buildNodeSizeStyle(HTMLAttributes)
|
||||||
|
},
|
||||||
|
HTMLAttributes
|
||||||
|
)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const ExtIframe = Node.create({
|
||||||
|
name: "iframe",
|
||||||
|
group: "block",
|
||||||
|
atom: true,
|
||||||
|
draggable: true,
|
||||||
|
selectable: true,
|
||||||
|
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
src: {
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
...buildNodeSizeAttributes("28rem")
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
parseHTML() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
tag: "iframe"
|
||||||
|
}
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, string> }) {
|
||||||
|
const style = buildNodeSizeStyle(HTMLAttributes);
|
||||||
|
return [
|
||||||
|
"iframe",
|
||||||
|
mergeAttributes(
|
||||||
|
{
|
||||||
|
class: "tp-iframe",
|
||||||
|
frameborder: "0",
|
||||||
|
allowfullscreen: "true",
|
||||||
|
referrerpolicy: "no-referrer-when-downgrade",
|
||||||
|
style
|
||||||
|
},
|
||||||
|
HTMLAttributes
|
||||||
|
)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export function buildTiptapExtensions(placeholder?: string): Extensions {
|
||||||
|
return [
|
||||||
|
StarterKit.configure({
|
||||||
|
codeBlock: false
|
||||||
|
}),
|
||||||
|
PrismCodeBlock.configure({
|
||||||
|
languageClassPrefix: "language-"
|
||||||
|
}),
|
||||||
|
Placeholder.configure({
|
||||||
|
placeholder: placeholder || "请输入内容"
|
||||||
|
}),
|
||||||
|
ExtImage,
|
||||||
|
ExtVideo,
|
||||||
|
ExtIframe,
|
||||||
|
Link.configure({
|
||||||
|
openOnClick: false,
|
||||||
|
autolink: true,
|
||||||
|
defaultProtocol: "https"
|
||||||
|
}),
|
||||||
|
Underline,
|
||||||
|
ExtTextStyle,
|
||||||
|
Color,
|
||||||
|
Subscript,
|
||||||
|
Superscript,
|
||||||
|
Table.configure({
|
||||||
|
resizable: true
|
||||||
|
}),
|
||||||
|
TableRow,
|
||||||
|
TableHeader,
|
||||||
|
TableCell,
|
||||||
|
TextAlign.configure({
|
||||||
|
types: ["heading", "paragraph"]
|
||||||
|
})
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import view from "./index.vue";
|
||||||
|
import Toolkit from "../../utils/Toolkit";
|
||||||
|
import type { TiptapEditorInstance } from "./useTiptapEditor";
|
||||||
|
|
||||||
|
export * from "./shared";
|
||||||
|
export type { TiptapEditorInstance } from "./useTiptapEditor";
|
||||||
|
export type TiptapEditorExpose = {
|
||||||
|
submit: () => Promise<string>;
|
||||||
|
getPendingMediaCount: () => number;
|
||||||
|
editor?: TiptapEditorInstance;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TiptapEditor = Toolkit.withInstall(view);
|
||||||
|
export default TiptapEditor;
|
||||||
|
|
||||||
@@ -0,0 +1,617 @@
|
|||||||
|
<template>
|
||||||
|
<div class="tp-editor">
|
||||||
|
<tiptap-toolbar
|
||||||
|
:editor="editor || undefined"
|
||||||
|
:padding-t-b="articlePadding.paddingTB"
|
||||||
|
:padding-l-r="articlePadding.paddingLR"
|
||||||
|
:show-padding-tool="showPaddingTool"
|
||||||
|
:source-code-mode="sourceCodeMode"
|
||||||
|
:upload-image="handleInsertImage"
|
||||||
|
:upload-video="handleInsertVideo"
|
||||||
|
@update:padding-t-b="articlePadding.paddingTB = $event"
|
||||||
|
@update:padding-l-r="articlePadding.paddingLR = $event"
|
||||||
|
@toggle-source-code="toggleSourceCodeMode"
|
||||||
|
/>
|
||||||
|
<div class="content">
|
||||||
|
<code-editor
|
||||||
|
v-if="sourceCodeMode"
|
||||||
|
ref="sourceEditorRef"
|
||||||
|
v-model="sourceCode"
|
||||||
|
class="source-editor"
|
||||||
|
language="html"
|
||||||
|
placeholder="请输入 HTML 源码"
|
||||||
|
/>
|
||||||
|
<div v-else-if="editor" class="value-wrap">
|
||||||
|
<editor-search-bar
|
||||||
|
ref="searchBarRef"
|
||||||
|
:visible="searchVisible"
|
||||||
|
:expanded="replaceExpanded"
|
||||||
|
:keyword="searchKeyword"
|
||||||
|
:replace-value="replaceKeyword"
|
||||||
|
:status-error="searchError"
|
||||||
|
:match-index="searchMatchIndex"
|
||||||
|
:match-count="searchMatchList.length"
|
||||||
|
:case-sensitive="searchOptions.caseSensitive"
|
||||||
|
:use-regex="searchOptions.useRegex"
|
||||||
|
@update:expanded="replaceExpanded = $event"
|
||||||
|
@update:keyword="searchKeyword = $event"
|
||||||
|
@update:replace-value="replaceKeyword = $event"
|
||||||
|
@update:case-sensitive="searchOptions.caseSensitive = $event"
|
||||||
|
@update:use-regex="searchOptions.useRegex = $event"
|
||||||
|
@previous="findPrevious"
|
||||||
|
@next="onSearchNext"
|
||||||
|
@replace="replaceCurrent"
|
||||||
|
@replace-all="replaceAll"
|
||||||
|
@close="closeSearch"
|
||||||
|
/>
|
||||||
|
<editor-content
|
||||||
|
class="value"
|
||||||
|
:style="editorBodyStyle"
|
||||||
|
:editor="editor"
|
||||||
|
:spellcheck="false"
|
||||||
|
@keydown.capture="onRichEditorKeydown"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<t-empty v-else description="编辑器初始化中" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import CodeEditor from "../code-editor/index.vue";
|
||||||
|
import EditorSearchBar from "../editor-search/index.vue";
|
||||||
|
import {
|
||||||
|
buildSearchRegex,
|
||||||
|
replaceMatchText,
|
||||||
|
type TextSearchOptions
|
||||||
|
} from "../editor-search/search";
|
||||||
|
import Toolkit from "../../utils/Toolkit";
|
||||||
|
import type { PendingMediaItem, UploadMediaType, UploadResult } from "./shared";
|
||||||
|
import TiptapToolbar from "./TiptapToolbar.vue";
|
||||||
|
import { useTiptapEditor } from "./useTiptapEditor";
|
||||||
|
import {
|
||||||
|
ATTACHMENT_ID_ATTR,
|
||||||
|
LOCAL_MEDIA_ID_PREFIX,
|
||||||
|
TEMP_FILE_ID_ATTR,
|
||||||
|
isLocalMediaId,
|
||||||
|
normalizeContent
|
||||||
|
} from "./shared";
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: "TiptapEditor"
|
||||||
|
});
|
||||||
|
|
||||||
|
type EditorSearchBarExpose = {
|
||||||
|
focusKeyword: (selectAll?: boolean) => void;
|
||||||
|
focusReplace: (selectAll?: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CodeEditorExpose = {
|
||||||
|
openSearch: (showReplace: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RichTextMatch = {
|
||||||
|
from: number;
|
||||||
|
to: number;
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
modelValue: string;
|
||||||
|
placeholder?: string;
|
||||||
|
paddingTB?: number;
|
||||||
|
paddingLR?: number;
|
||||||
|
showPaddingTool?: boolean;
|
||||||
|
uploadImage?: (file: File) => Promise<UploadResult>;
|
||||||
|
uploadVideo?: (file: File) => Promise<UploadResult>;
|
||||||
|
}>(), {
|
||||||
|
modelValue: "",
|
||||||
|
placeholder: "请输入内容",
|
||||||
|
paddingTB: 1,
|
||||||
|
paddingLR: 1.25,
|
||||||
|
showPaddingTool: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
"update:modelValue": [value: string];
|
||||||
|
"change": [value: string];
|
||||||
|
"update:paddingTB": [value: number];
|
||||||
|
"update:paddingLR": [value: number];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const { EditorContent, articlePadding, editor, editorBodyStyle } = useTiptapEditor(props, emit);
|
||||||
|
const pendingMediaMap = reactive(new Map<string, PendingMediaItem>());
|
||||||
|
const sourceCodeMode = ref(false);
|
||||||
|
const sourceCode = ref("");
|
||||||
|
const sourceEditorRef = ref<CodeEditorExpose>();
|
||||||
|
const searchBarRef = ref<EditorSearchBarExpose>();
|
||||||
|
const searchVisible = ref(false);
|
||||||
|
const replaceExpanded = ref(false);
|
||||||
|
const searchKeyword = ref("");
|
||||||
|
const replaceKeyword = ref("");
|
||||||
|
const searchError = ref("");
|
||||||
|
const searchMatchIndex = ref(-1);
|
||||||
|
const searchMatchList = ref<RichTextMatch[]>([]);
|
||||||
|
const searchOptions = reactive<TextSearchOptions>({
|
||||||
|
caseSensitive: false,
|
||||||
|
useRegex: false
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
function buildLocalMediaId() {
|
||||||
|
return `${LOCAL_MEDIA_ID_PREFIX}${Toolkit.uuid()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function revokeMediaUrl(url?: string) {
|
||||||
|
if (url?.startsWith("blob:")) {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removePendingMedia(id: string) {
|
||||||
|
const item = pendingMediaMap.get(id);
|
||||||
|
if (!item) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
revokeMediaUrl(item.url);
|
||||||
|
pendingMediaMap.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearPendingMedia() {
|
||||||
|
Array.from(pendingMediaMap.keys()).forEach(removePendingMedia);
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectMediaIds(content?: string) {
|
||||||
|
const rawContent = normalizeContent(content);
|
||||||
|
if (!rawContent) {
|
||||||
|
return new Set<string>();
|
||||||
|
}
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const doc = parser.parseFromString(rawContent, "text/html");
|
||||||
|
const mediaList = Array.from(doc.querySelectorAll("img, video"));
|
||||||
|
return new Set(
|
||||||
|
mediaList
|
||||||
|
.map(item => item.getAttribute(ATTACHMENT_ID_ATTR) || item.getAttribute(TEMP_FILE_ID_ATTR) || "")
|
||||||
|
.filter(Boolean)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncPendingMedia(content?: string) {
|
||||||
|
const mediaIdSet = collectMediaIds(content);
|
||||||
|
Array.from(pendingMediaMap.keys()).forEach((id) => {
|
||||||
|
if (!mediaIdSet.has(id)) {
|
||||||
|
removePendingMedia(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleInsertMedia(file: File, type: UploadMediaType): Promise<UploadResult> {
|
||||||
|
const id = buildLocalMediaId();
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
pendingMediaMap.set(id, {
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
name: file.name,
|
||||||
|
file,
|
||||||
|
url
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
url,
|
||||||
|
name: file.name
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleInsertImage(file: File) {
|
||||||
|
return handleInsertMedia(file, "image");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleInsertVideo(file: File) {
|
||||||
|
return handleInsertMedia(file, "video");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
const instance = editor.value;
|
||||||
|
if (!instance) {
|
||||||
|
return normalizeContent(props.modelValue);
|
||||||
|
}
|
||||||
|
const rawContent = normalizeContent(instance.getHTML());
|
||||||
|
if (!rawContent) {
|
||||||
|
clearPendingMedia();
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const doc = parser.parseFromString(rawContent, "text/html");
|
||||||
|
const mediaList = Array.from(doc.querySelectorAll("img, video"));
|
||||||
|
const uploadedMediaIdList: string[] = [];
|
||||||
|
|
||||||
|
for (const media of mediaList) {
|
||||||
|
const attachmentId = media.getAttribute(ATTACHMENT_ID_ATTR) || media.getAttribute(TEMP_FILE_ID_ATTR) || "";
|
||||||
|
if (!isLocalMediaId(attachmentId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const pendingMedia = pendingMediaMap.get(attachmentId);
|
||||||
|
if (!pendingMedia) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const uploadFn = pendingMedia.type === "image" ? props.uploadImage : props.uploadVideo;
|
||||||
|
if (!uploadFn) {
|
||||||
|
throw new Error(`未配置${pendingMedia.type === "image" ? "图片" : "视频"}上传方法`);
|
||||||
|
}
|
||||||
|
const result = await uploadFn(pendingMedia.file);
|
||||||
|
media.setAttribute("src", result.url);
|
||||||
|
media.setAttribute(ATTACHMENT_ID_ATTR, result.id);
|
||||||
|
media.setAttribute(TEMP_FILE_ID_ATTR, result.id);
|
||||||
|
if (media.tagName.toLowerCase() === "img") {
|
||||||
|
media.setAttribute("alt", result.name || pendingMedia.name);
|
||||||
|
media.setAttribute("title", result.name || pendingMedia.name);
|
||||||
|
} else {
|
||||||
|
media.setAttribute("title", result.name || pendingMedia.name);
|
||||||
|
}
|
||||||
|
uploadedMediaIdList.push(attachmentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextContent = normalizeContent(doc.body.innerHTML);
|
||||||
|
if (nextContent !== rawContent) {
|
||||||
|
instance.commands.setContent(nextContent || "<p></p>");
|
||||||
|
}
|
||||||
|
uploadedMediaIdList.forEach(removePendingMedia);
|
||||||
|
syncPendingMedia(nextContent);
|
||||||
|
return nextContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncSourceCode(value?: string) {
|
||||||
|
sourceCode.value = value || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSourceCodeChange(value: string) {
|
||||||
|
sourceCode.value = value;
|
||||||
|
emit("change", value);
|
||||||
|
emit("update:modelValue", value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSelectedEditorText() {
|
||||||
|
const instance = editor.value;
|
||||||
|
if (!instance) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const { from, to } = instance.state.selection;
|
||||||
|
if (from === to) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return instance.state.doc.textBetween(from, to, "\n", "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshSearchMatchState() {
|
||||||
|
const instance = editor.value;
|
||||||
|
if (!instance || sourceCodeMode.value) {
|
||||||
|
searchError.value = "";
|
||||||
|
searchMatchList.value = [];
|
||||||
|
searchMatchIndex.value = -1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { regex, error } = buildSearchRegex(searchKeyword.value, searchOptions, true);
|
||||||
|
searchError.value = error;
|
||||||
|
if (!regex || error || !searchKeyword.value) {
|
||||||
|
searchMatchList.value = [];
|
||||||
|
searchMatchIndex.value = -1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const matchList: RichTextMatch[] = [];
|
||||||
|
instance.state.doc.descendants((node, pos) => {
|
||||||
|
if (!node.isText || !node.text) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
regex.lastIndex = 0;
|
||||||
|
let match = regex.exec(node.text);
|
||||||
|
while (match) {
|
||||||
|
if (!match[0]) {
|
||||||
|
searchError.value = "正则不能匹配空文本";
|
||||||
|
searchMatchList.value = [];
|
||||||
|
searchMatchIndex.value = -1;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
matchList.push({
|
||||||
|
from: pos + match.index,
|
||||||
|
to: pos + match.index + match[0].length,
|
||||||
|
text: match[0]
|
||||||
|
});
|
||||||
|
match = regex.exec(node.text);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
searchMatchList.value = matchList;
|
||||||
|
const { from, to } = instance.state.selection;
|
||||||
|
searchMatchIndex.value = matchList.findIndex((match) => match.from === from && match.to === to);
|
||||||
|
if (0 > searchMatchIndex.value && 0 < matchList.length) {
|
||||||
|
searchMatchIndex.value = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSearch(showReplace: boolean) {
|
||||||
|
if (sourceCodeMode.value) {
|
||||||
|
sourceEditorRef.value?.openSearch(showReplace);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
searchVisible.value = true;
|
||||||
|
replaceExpanded.value = showReplace;
|
||||||
|
const selectedText = getSelectedEditorText();
|
||||||
|
if (selectedText) {
|
||||||
|
searchKeyword.value = selectedText;
|
||||||
|
}
|
||||||
|
refreshSearchMatchState();
|
||||||
|
nextTick(() => {
|
||||||
|
searchBarRef.value?.focusKeyword(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSearch() {
|
||||||
|
searchVisible.value = false;
|
||||||
|
replaceExpanded.value = false;
|
||||||
|
editor.value?.commands.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectMatch(match: RichTextMatch, matchIndex: number) {
|
||||||
|
const instance = editor.value;
|
||||||
|
if (!instance) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
instance.chain().focus().setTextSelection({ from: match.from, to: match.to }).scrollIntoView().run();
|
||||||
|
searchMatchIndex.value = matchIndex;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findNext() {
|
||||||
|
const instance = editor.value;
|
||||||
|
if (!instance || searchError.value || 1 > searchMatchList.value.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const currentTo = instance.state.selection.to;
|
||||||
|
const nextIndex = searchMatchList.value.findIndex((match) => match.from >= currentTo);
|
||||||
|
const targetIndex = 0 <= nextIndex ? nextIndex : 0;
|
||||||
|
return selectMatch(searchMatchList.value[targetIndex], targetIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findPrevious() {
|
||||||
|
const instance = editor.value;
|
||||||
|
if (!instance || searchError.value || 1 > searchMatchList.value.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const currentFrom = instance.state.selection.from;
|
||||||
|
let targetIndex = searchMatchList.value.length - 1;
|
||||||
|
for (let index = searchMatchList.value.length - 1; 0 <= index; index -= 1) {
|
||||||
|
if (searchMatchList.value[index].from < currentFrom) {
|
||||||
|
targetIndex = index;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return selectMatch(searchMatchList.value[targetIndex], targetIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceCurrent() {
|
||||||
|
const instance = editor.value;
|
||||||
|
if (!instance || !searchKeyword.value) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let currentMatchIndex = searchMatchList.value.findIndex((match) => (
|
||||||
|
match.from === instance.state.selection.from && match.to === instance.state.selection.to
|
||||||
|
));
|
||||||
|
if (0 > currentMatchIndex) {
|
||||||
|
if (!findNext()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
currentMatchIndex = searchMatchIndex.value;
|
||||||
|
}
|
||||||
|
const currentMatch = searchMatchList.value[currentMatchIndex];
|
||||||
|
if (!currentMatch) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const nextText = replaceMatchText(currentMatch.text, searchKeyword.value, replaceKeyword.value, searchOptions);
|
||||||
|
const transaction = instance.state.tr.insertText(nextText, currentMatch.from, currentMatch.to);
|
||||||
|
instance.view.dispatch(transaction);
|
||||||
|
refreshSearchMatchState();
|
||||||
|
nextTick(() => {
|
||||||
|
const nextMatch = searchMatchList.value[currentMatchIndex] || searchMatchList.value[currentMatchIndex - 1];
|
||||||
|
if (!nextMatch) {
|
||||||
|
instance.commands.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
selectMatch(nextMatch, Math.min(currentMatchIndex, searchMatchList.value.length - 1));
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceAll() {
|
||||||
|
const instance = editor.value;
|
||||||
|
if (!instance || !searchKeyword.value) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (searchError.value || 1 > searchMatchList.value.length) {
|
||||||
|
refreshSearchMatchState();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const replaceCount = searchMatchList.value.length;
|
||||||
|
const transaction = instance.state.tr;
|
||||||
|
for (let index = searchMatchList.value.length - 1; 0 <= index; index -= 1) {
|
||||||
|
const match = searchMatchList.value[index];
|
||||||
|
const nextText = replaceMatchText(match.text, searchKeyword.value, replaceKeyword.value, searchOptions);
|
||||||
|
transaction.insertText(nextText, match.from, match.to);
|
||||||
|
}
|
||||||
|
instance.view.dispatch(transaction);
|
||||||
|
refreshSearchMatchState();
|
||||||
|
nextTick(() => {
|
||||||
|
if (searchMatchList.value[0]) {
|
||||||
|
selectMatch(searchMatchList.value[0], 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
instance.commands.focus();
|
||||||
|
});
|
||||||
|
return replaceCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSearchNext(shiftKey: boolean) {
|
||||||
|
if (shiftKey) {
|
||||||
|
findPrevious();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
findNext();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRichEditorKeydown(event: KeyboardEvent) {
|
||||||
|
if (sourceCodeMode.value || event.altKey || !(event.ctrlKey || event.metaKey)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const key = event.key.toLowerCase();
|
||||||
|
if ("f" === key) {
|
||||||
|
event.preventDefault();
|
||||||
|
openSearch(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ("h" === key) {
|
||||||
|
event.preventDefault();
|
||||||
|
openSearch(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSourceCodeMode() {
|
||||||
|
const nextMode = !sourceCodeMode.value;
|
||||||
|
closeSearch();
|
||||||
|
if (nextMode) {
|
||||||
|
syncSourceCode(normalizeContent(editor.value?.getHTML()) || props.modelValue);
|
||||||
|
sourceCodeMode.value = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sourceCodeMode.value = false;
|
||||||
|
const nextContent = normalizeContent(sourceCode.value);
|
||||||
|
if (editor.value) {
|
||||||
|
editor.value.commands.setContent(nextContent || "<p></p>");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.modelValue, (value) => {
|
||||||
|
syncPendingMedia(value);
|
||||||
|
syncSourceCode(value);
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
watch(sourceCode, (value) => {
|
||||||
|
if (!sourceCodeMode.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onSourceCodeChange(value);
|
||||||
|
});
|
||||||
|
|
||||||
|
watch([searchKeyword, () => searchOptions.caseSensitive, () => searchOptions.useRegex, sourceCodeMode], () => {
|
||||||
|
refreshSearchMatchState();
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(() => editor.value?.state.selection.from, () => {
|
||||||
|
if (!searchVisible.value || sourceCodeMode.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
refreshSearchMatchState();
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
clearPendingMedia();
|
||||||
|
});
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
editor,
|
||||||
|
submit,
|
||||||
|
getPendingMediaCount: () => pendingMediaMap.size,
|
||||||
|
openSearch,
|
||||||
|
closeSearch,
|
||||||
|
findNext,
|
||||||
|
findPrevious,
|
||||||
|
replaceCurrent,
|
||||||
|
replaceAll
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.tp-editor {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: 1px solid var(--td-component-border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.content {
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
cursor: text;
|
||||||
|
padding: 2rem 10rem;
|
||||||
|
display: flex;
|
||||||
|
overflow: auto;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: var(--td-bg-color-page);
|
||||||
|
|
||||||
|
.value-wrap {
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 100%;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 100%;
|
||||||
|
overflow: auto;
|
||||||
|
background: var(--td-bg-color-container);
|
||||||
|
box-shadow: var(--td-shadow-1);
|
||||||
|
border-radius: var(--td-radius-default);
|
||||||
|
|
||||||
|
:deep(.editor-body) {
|
||||||
|
outline: none;
|
||||||
|
padding: var(--tp-editor-padding-tb) var(--tp-editor-padding-lr);
|
||||||
|
min-height: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
pre[class*='language-'] > code {
|
||||||
|
padding: .25rem .5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
p.is-editor-empty:first-child:before {
|
||||||
|
height: 0;
|
||||||
|
float: left;
|
||||||
|
color: var(--td-text-color-placeholder);
|
||||||
|
content: attr(data-placeholder);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
iframe.tp-iframe {
|
||||||
|
margin-right: auto;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-editor {
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 100%;
|
||||||
|
background: var(--td-bg-color-container);
|
||||||
|
border-radius: var(--td-radius-default);
|
||||||
|
box-shadow: var(--td-shadow-1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 48rem) {
|
||||||
|
.tp-editor {
|
||||||
|
.content {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { h } from "vue";
|
||||||
|
|
||||||
|
export type UploadResult = {
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
name?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UploadMediaType = "image" | "video";
|
||||||
|
export type PendingMediaItem = {
|
||||||
|
id: string;
|
||||||
|
type: UploadMediaType;
|
||||||
|
name: string;
|
||||||
|
file: File;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AlignType = "left" | "center" | "right" | "justify";
|
||||||
|
|
||||||
|
export const ATTACHMENT_ID_ATTR = "data-attachment-id";
|
||||||
|
export const TEMP_FILE_ID_ATTR = "data-temp-file-id";
|
||||||
|
export const TABLE_PICKER_SIZE = 7;
|
||||||
|
export const LOCAL_MEDIA_ID_PREFIX = "local-media:";
|
||||||
|
|
||||||
|
export const headingMeta = [
|
||||||
|
{ label: "正文", value: "p", className: "p" },
|
||||||
|
{ label: "标题 1", value: "h1", className: "h1" },
|
||||||
|
{ label: "标题 2", value: "h2", className: "h2" },
|
||||||
|
{ label: "标题 3", value: "h3", className: "h3" },
|
||||||
|
{ label: "标题 4", value: "h4", className: "h4" },
|
||||||
|
{ label: "标题 5", value: "h5", className: "h5" },
|
||||||
|
{ label: "标题 6", value: "h6", className: "h6" }
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function buildHeadingOptions(currentHeadingValue: string) {
|
||||||
|
return headingMeta.map((item) => {
|
||||||
|
const active = currentHeadingValue === item.value;
|
||||||
|
return {
|
||||||
|
active,
|
||||||
|
value: item.value,
|
||||||
|
content: () => h(
|
||||||
|
"span",
|
||||||
|
{ class: ["tp-heading-option", item.className] },
|
||||||
|
item.label
|
||||||
|
)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeContent(value?: string) {
|
||||||
|
const text = (value || "").trim();
|
||||||
|
if (!text || text === "<p></p>") {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const doc = parser.parseFromString(text, "text/html");
|
||||||
|
const normalized = doc.body.innerHTML.trim();
|
||||||
|
return normalized === "<p></p>" ? "" : normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizePaddingValue(value: unknown, fallback: number) {
|
||||||
|
const nextValue = typeof value === "number" ? value : Number(value);
|
||||||
|
if (!Number.isFinite(nextValue)) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
return Math.min(Math.max(nextValue, 0), 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeSliderValue(value: number | number[], fallback: number) {
|
||||||
|
const nextValue = Array.isArray(value) ? value[0] : value;
|
||||||
|
return normalizePaddingValue(nextValue, fallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPaddingValue(value: number) {
|
||||||
|
return `${Number(value.toFixed(2))}rem`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLocalMediaId(id?: string | null) {
|
||||||
|
return !!id && id.startsWith(LOCAL_MEDIA_ID_PREFIX);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import type { Editor } from "@tiptap/vue-3";
|
||||||
|
import { EditorContent, useEditor } from "@tiptap/vue-3";
|
||||||
|
import { buildTiptapExtensions } from "./extensions";
|
||||||
|
import { normalizeContent, normalizePaddingValue } from "./shared";
|
||||||
|
|
||||||
|
type TiptapEditorProps = {
|
||||||
|
modelValue: string;
|
||||||
|
paddingTB?: number;
|
||||||
|
paddingLR?: number;
|
||||||
|
placeholder?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UpdateModelValue = {
|
||||||
|
(event: "update:modelValue", value: string): void;
|
||||||
|
(event: "change", value: string): void;
|
||||||
|
(event: "update:paddingTB", value: number): void;
|
||||||
|
(event: "update:paddingLR", value: number): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useTiptapEditor(props: TiptapEditorProps, emit: UpdateModelValue) {
|
||||||
|
const articlePadding = reactive({
|
||||||
|
paddingTB: normalizePaddingValue(props.paddingTB, 1),
|
||||||
|
paddingLR: normalizePaddingValue(props.paddingLR, 1.25)
|
||||||
|
});
|
||||||
|
const lastEmittedContent = ref("");
|
||||||
|
const pendingEmitContent = ref<string | null>(null);
|
||||||
|
|
||||||
|
// 代码块输入期间只在编辑器内部累积,离开代码块或失焦后再统一同步
|
||||||
|
function flushPendingContent() {
|
||||||
|
if (pendingEmitContent.value === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastEmittedContent.value = pendingEmitContent.value;
|
||||||
|
emit("change", pendingEmitContent.value);
|
||||||
|
emit("update:modelValue", pendingEmitContent.value);
|
||||||
|
pendingEmitContent.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyExternalContent(nextContent: string) {
|
||||||
|
if (!editor.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
editor.value.commands.setContent(nextContent || "<p></p>", {
|
||||||
|
emitUpdate: false
|
||||||
|
});
|
||||||
|
lastEmittedContent.value = nextContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
const editorBodyStyle = computed(() => {
|
||||||
|
return {
|
||||||
|
"--tp-editor-padding-tb": `${articlePadding.paddingTB}rem`,
|
||||||
|
"--tp-editor-padding-lr": `${articlePadding.paddingLR}rem`
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const editor = useEditor({
|
||||||
|
extensions: buildTiptapExtensions(props.placeholder),
|
||||||
|
content: "",
|
||||||
|
editorProps: {
|
||||||
|
attributes: {
|
||||||
|
class: "editor-body tp-content-body tp-editor-body"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onBlur: () => {
|
||||||
|
flushPendingContent();
|
||||||
|
},
|
||||||
|
onSelectionUpdate: ({ editor: instance }) => {
|
||||||
|
if (instance.isActive("codeBlock")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
flushPendingContent();
|
||||||
|
},
|
||||||
|
onUpdate: ({ editor: instance }) => {
|
||||||
|
const nextContent = normalizeContent(instance.getHTML());
|
||||||
|
lastEmittedContent.value = nextContent;
|
||||||
|
if (instance.isActive("codeBlock")) {
|
||||||
|
pendingEmitContent.value = nextContent;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pendingEmitContent.value = null;
|
||||||
|
emit("change", nextContent);
|
||||||
|
emit("update:modelValue", nextContent);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(() => props.modelValue, (value) => {
|
||||||
|
if (!editor.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextContent = normalizeContent(value);
|
||||||
|
const currentContent = normalizeContent(editor.value.getHTML());
|
||||||
|
if (nextContent === currentContent) {
|
||||||
|
lastEmittedContent.value = nextContent;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (nextContent === lastEmittedContent.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 聚焦编辑时禁止外部回灌,避免重建选区后把光标拉回开头
|
||||||
|
if (editor.value.isFocused) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
applyExternalContent(nextContent);
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
watch(() => props.paddingTB, (value) => {
|
||||||
|
articlePadding.paddingTB = normalizePaddingValue(value, articlePadding.paddingTB);
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
watch(() => props.paddingLR, (value) => {
|
||||||
|
articlePadding.paddingLR = normalizePaddingValue(value, articlePadding.paddingLR);
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
watch(() => articlePadding.paddingTB, (value) => {
|
||||||
|
emit("update:paddingTB", value);
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(() => articlePadding.paddingLR, (value) => {
|
||||||
|
emit("update:paddingLR", value);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
editor.value?.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
EditorContent,
|
||||||
|
articlePadding,
|
||||||
|
editor,
|
||||||
|
editorBodyStyle
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TiptapEditorInstance = Editor;
|
||||||
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import view from "./index.vue";
|
||||||
|
import Toolkit from "../../utils/Toolkit";
|
||||||
|
|
||||||
|
export const TiptapView = Toolkit.withInstall(view);
|
||||||
|
export default TiptapView;
|
||||||
|
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
<template>
|
||||||
|
<div class="tp-view selectable">
|
||||||
|
<div v-if="renderContent && editor" ref="contentRef" class="content-wrap" :data-max-height="maxHeight">
|
||||||
|
<editor-content class="content" :editor="editor" />
|
||||||
|
</div>
|
||||||
|
<div v-else class="empty">
|
||||||
|
<span v-text="emptyText" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import {EditorContent, useEditor} from "@tiptap/vue-3";
|
||||||
|
import {buildTiptapExtensions} from "../tiptap-editor/extensions";
|
||||||
|
import {normalizeContent} from "../tiptap-editor/shared";
|
||||||
|
import {enhancePrismCodeBlock} from "../../utils/PrismCodeBlock";
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: "TiptapView"
|
||||||
|
});
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
content?: string;
|
||||||
|
emptyText?: string;
|
||||||
|
maxHeight?: string;
|
||||||
|
}>(), {
|
||||||
|
content: "",
|
||||||
|
emptyText: "暂无内容",
|
||||||
|
maxHeight: "400px"
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderContent = computed(() => {
|
||||||
|
return normalizeContent(props.content);
|
||||||
|
});
|
||||||
|
const contentRef = ref<HTMLElement>();
|
||||||
|
|
||||||
|
async function syncCodeBlock() {
|
||||||
|
await nextTick();
|
||||||
|
if (contentRef.value) {
|
||||||
|
enhancePrismCodeBlock(contentRef.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const editor = useEditor({
|
||||||
|
editable: false,
|
||||||
|
extensions: buildTiptapExtensions(),
|
||||||
|
content: renderContent.value || "<p></p>",
|
||||||
|
editorProps: {
|
||||||
|
attributes: {
|
||||||
|
class: "editor-body tp-content-body"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(() => [renderContent.value, props.maxHeight] as const, ([value]) => {
|
||||||
|
if (!editor.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
editor.value.commands.setContent(value || "<p></p>", {
|
||||||
|
emitUpdate: false
|
||||||
|
});
|
||||||
|
void syncCodeBlock();
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
watch(editor, () => {
|
||||||
|
void syncCodeBlock();
|
||||||
|
}, { flush: "post" });
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
editor.value?.destroy();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.tp-view {
|
||||||
|
width: 100%;
|
||||||
|
.empty {
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: .375rem;
|
||||||
|
color: var(--td-text-color-placeholder);
|
||||||
|
background: var(--td-bg-color-container);
|
||||||
|
border: 1px solid var(--td-component-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
border: 1px solid var(--td-component-border);
|
||||||
|
padding: 1rem;
|
||||||
|
min-height: 12rem;
|
||||||
|
background: var(--td-bg-color-container);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
@@ -4,7 +4,11 @@ import components from "./components";
|
|||||||
|
|
||||||
import "./assets/style/variable.less";
|
import "./assets/style/variable.less";
|
||||||
import "./assets/style/timi-web.less";
|
import "./assets/style/timi-web.less";
|
||||||
|
import "prismjs/themes/prism.css";
|
||||||
|
import "./components/prism-code-block.less";
|
||||||
|
import "./components/tiptap-content.less";
|
||||||
import { Network } from "./utils";
|
import { Network } from "./utils";
|
||||||
|
import VPopup from "./utils/directives/Popup";
|
||||||
|
|
||||||
export * from "./api";
|
export * from "./api";
|
||||||
export * from "./components";
|
export * from "./components";
|
||||||
@@ -18,6 +22,7 @@ const install = function (app: App) {
|
|||||||
components.forEach(component => {
|
components.forEach(component => {
|
||||||
app.use(component as unknown as { install: () => any });
|
app.use(component as unknown as { install: () => any });
|
||||||
});
|
});
|
||||||
|
app.directive("popup", VPopup);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
|||||||
@@ -32,23 +32,23 @@ const isShortScreen = ref(false);
|
|||||||
const isMobileLayout = ref(false);
|
const isMobileLayout = ref(false);
|
||||||
|
|
||||||
/** 断点配置,单位:px */
|
/** 断点配置,单位:px */
|
||||||
enum Breakpoints {
|
const Breakpoints = {
|
||||||
|
|
||||||
/** 超小设备 */
|
/** 超小设备 */
|
||||||
XS = 480,
|
XS: 480,
|
||||||
|
|
||||||
/** 手机 */
|
/** 手机 */
|
||||||
SM = 650,
|
SM: 650,
|
||||||
|
|
||||||
/** 平板 */
|
/** 平板 */
|
||||||
MD = 768,
|
MD: 768,
|
||||||
|
|
||||||
/** 笔记本 */
|
/** 笔记本 */
|
||||||
LG = 1024,
|
LG: 1024,
|
||||||
|
|
||||||
/** 大屏幕 */
|
/** 大屏幕 */
|
||||||
XL = 1440
|
XL: 1440
|
||||||
}
|
} as const;
|
||||||
|
|
||||||
Resizer.addListener("DEVICE_SIZE", (width, height) => {
|
Resizer.addListener("DEVICE_SIZE", (width, height) => {
|
||||||
screenWidth.value = width;
|
screenWidth.value = width;
|
||||||
|
|||||||
+47
-83
@@ -1,88 +1,52 @@
|
|||||||
import type { Attachment } from "./Attachment";
|
import type { Attachment } from "./Attachment";
|
||||||
|
import type { Comment } from "./Comment";
|
||||||
import type { Model } from "./Model";
|
import type { Model } from "./Model";
|
||||||
|
import type { Setting } from "./Setting";
|
||||||
|
|
||||||
// 文章
|
/** 文章业务类型 */
|
||||||
export type Article<E extends ArticleMusicExtendData | ArticleSoftwareExtendData> = {
|
export type ArticleBizType = "BLOG" | "FEEDBACK";
|
||||||
|
|
||||||
|
/** 文章正文类型 */
|
||||||
|
export type ArticleContentType = "TEXT" | "MARKDOWN" | "TIP_TAP";
|
||||||
|
|
||||||
|
/** 文章状态 */
|
||||||
|
export type ArticleStatus = "DART" | "NORMAL" | "HIDDEN";
|
||||||
|
|
||||||
|
/** 文章实体 */
|
||||||
|
export type Article = {
|
||||||
|
/** 业务类型 */
|
||||||
|
bizType?: ArticleBizType;
|
||||||
|
|
||||||
|
/** 业务 ID */
|
||||||
|
bizId?: string;
|
||||||
|
|
||||||
|
/** 标题 */
|
||||||
title?: string;
|
title?: string;
|
||||||
type: ArticleType;
|
|
||||||
digest?: string;
|
/** 描述 */
|
||||||
data?: string;
|
description?: string;
|
||||||
extendData?: E;
|
|
||||||
reads: number;
|
/** 正文类型 */
|
||||||
likes: number;
|
contentType?: ArticleContentType;
|
||||||
showComment: boolean;
|
|
||||||
canComment: boolean;
|
/** 正文内容 */
|
||||||
canRanking: boolean;
|
content?: string;
|
||||||
|
|
||||||
|
/** 阅读数量 */
|
||||||
|
reads?: number;
|
||||||
|
|
||||||
|
/** 喜欢数量 */
|
||||||
|
likes?: number;
|
||||||
|
|
||||||
|
/** 文章状态 */
|
||||||
|
status?: ArticleStatus;
|
||||||
|
|
||||||
|
/** 设置列表 */
|
||||||
|
settingList?: Setting[];
|
||||||
|
|
||||||
|
/** 评论列表 */
|
||||||
|
commentList?: Comment[];
|
||||||
|
|
||||||
|
/** 附件列表 */
|
||||||
|
attachmentList?: Attachment[];
|
||||||
} & Model;
|
} & Model;
|
||||||
|
|
||||||
export type ArticleView<E extends ArticleMusicExtendData | ArticleSoftwareExtendData> = {
|
|
||||||
comments?: number;
|
|
||||||
attachmentList: Attachment[];
|
|
||||||
} & Article<E>;
|
|
||||||
|
|
||||||
export enum ArticleType {
|
|
||||||
|
|
||||||
/** 公版 */
|
|
||||||
PUBLIC,
|
|
||||||
|
|
||||||
/** 音乐 */
|
|
||||||
MUSIC,
|
|
||||||
|
|
||||||
/** 软件 */
|
|
||||||
SOFTWARE
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum ArticleAttachType {
|
|
||||||
|
|
||||||
COVER,
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ArticleMusicExtendData = {
|
|
||||||
title: string;
|
|
||||||
list: ArticleMusicItem[];
|
|
||||||
info: {
|
|
||||||
key: string;
|
|
||||||
value: string;
|
|
||||||
}[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ArticleSoftwareExtendData = {
|
|
||||||
url?: string;
|
|
||||||
downloads: ArticleSoftwareDownload[];
|
|
||||||
format: string;
|
|
||||||
runtime: ArticleSoftwareRuntime[];
|
|
||||||
size: number;
|
|
||||||
version: string;
|
|
||||||
password?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum ArticleSoftwareDownloadType {
|
|
||||||
|
|
||||||
TIMI_MONGO,
|
|
||||||
|
|
||||||
TIMI_COS,
|
|
||||||
|
|
||||||
URL
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum ArticleSoftwareRuntime {
|
|
||||||
|
|
||||||
JVM,
|
|
||||||
|
|
||||||
WINDOWS,
|
|
||||||
|
|
||||||
LINUX,
|
|
||||||
|
|
||||||
MAC_OS
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ArticleSoftwareDownload = {
|
|
||||||
type: ArticleSoftwareDownloadType;
|
|
||||||
value: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ArticleMusicItem = {
|
|
||||||
title: string;
|
|
||||||
audio?: string;
|
|
||||||
video?: string;
|
|
||||||
}
|
|
||||||
|
|||||||
+23
-17
@@ -1,14 +1,15 @@
|
|||||||
import { Model } from "./Model";
|
import { Model } from "./Model";
|
||||||
|
|
||||||
export type Attachment = {
|
export type Attachment = {
|
||||||
bizType: AttachmentBizType;
|
bizType?: AttachmentBizType;
|
||||||
bizId: string;
|
bizId?: string;
|
||||||
attachType?: string;
|
attachType?: string;
|
||||||
mongoId: string;
|
mongoId?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
mimeType?: string;
|
mimeType?: string;
|
||||||
metadata?: unknown;
|
metadata?: Record<string, any>;
|
||||||
|
accessKey?: AttachmentAccessKey;
|
||||||
size?: number;
|
size?: number;
|
||||||
md5?: string;
|
md5?: string;
|
||||||
uploaderIp?: string;
|
uploaderIp?: string;
|
||||||
@@ -17,26 +18,31 @@ export type Attachment = {
|
|||||||
tempFileId?: string;
|
tempFileId?: string;
|
||||||
} & Model;
|
} & Model;
|
||||||
|
|
||||||
export type TempFileResp = {
|
export type TempFile = {
|
||||||
id: string;
|
id: string;
|
||||||
expireAt?: number;
|
expireAt?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type BizUpdateReq = {
|
export type BizIdUpdate = {
|
||||||
bizType: AttachmentBizType;
|
bizType: AttachmentBizType;
|
||||||
bizId: string;
|
bizId: string;
|
||||||
items: Partial<Attachment>[];
|
items: Partial<Attachment>[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export enum AttachmentBizType {
|
export type AttachmentBizType =
|
||||||
|
| "ARTICLE"
|
||||||
|
| "USER"
|
||||||
|
| "GAO_CUSTOMER"
|
||||||
|
| "GAO_EVENT_RECORD"
|
||||||
|
| "GAO_REGISTER_RECORD"
|
||||||
|
| "TEMP_FILE"
|
||||||
|
| "MIRROR";
|
||||||
|
|
||||||
USER = "USER",
|
/** 附件访问密钥 */
|
||||||
|
export type AttachmentAccessKey = {
|
||||||
GAO_CUSTOMER = "GAO_CUSTOMER",
|
value: string;
|
||||||
|
attachmentId: string;
|
||||||
GAO_REGISTER_RECORD = "GAO_REGISTER_RECORD",
|
isDisposable: boolean;
|
||||||
|
expiredAt: number;
|
||||||
TEMP_FILE = "TEMP_FILE",
|
createdAt: number;
|
||||||
|
};
|
||||||
MIRROR = "MIRROR"
|
|
||||||
}
|
|
||||||
|
|||||||
+3
-32
@@ -13,10 +13,10 @@ export type Comment = {
|
|||||||
user?: User;
|
user?: User;
|
||||||
|
|
||||||
/** 回复列表 */
|
/** 回复列表 */
|
||||||
replies?: CommentReply[];
|
replies?: Comment[];
|
||||||
|
|
||||||
/** 回复分页 */
|
/** 回复分页 */
|
||||||
repliesPage?: Page<CommentReply>;
|
repliesPage?: Page<Comment>;
|
||||||
|
|
||||||
/** 用于绑定组件当前页下标 */
|
/** 用于绑定组件当前页下标 */
|
||||||
repliesCurrent?: number;
|
repliesCurrent?: number;
|
||||||
@@ -31,33 +31,4 @@ export type Comment = {
|
|||||||
repository?: object;
|
repository?: object;
|
||||||
} & Model
|
} & Model
|
||||||
|
|
||||||
export type CommentReply = {
|
export type CommentBizType = "ARTICLE" | "GIT_ISSUE" | "GIT_MERGE";
|
||||||
replyId?: number;
|
|
||||||
commentId?: number;
|
|
||||||
senderId?: number;
|
|
||||||
senderNick?: string;
|
|
||||||
receiverId?: number;
|
|
||||||
receiverNick?: string;
|
|
||||||
content?: string;
|
|
||||||
|
|
||||||
comment?: Comment;
|
|
||||||
sender?: User;
|
|
||||||
receiver?: User;
|
|
||||||
} & Model;
|
|
||||||
|
|
||||||
export enum CommentReplyBizType {
|
|
||||||
|
|
||||||
COMMENT = "COMMENT",
|
|
||||||
|
|
||||||
SENDER = "SENDER",
|
|
||||||
|
|
||||||
RECEIVER = "RECEIVER"
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum CommentBizType {
|
|
||||||
ARTICLE = "ARTICLE",
|
|
||||||
|
|
||||||
GIT_ISSUE = "GIT_ISSUE",
|
|
||||||
|
|
||||||
GIT_MERGE = "GIT_MERGE",
|
|
||||||
}
|
|
||||||
|
|||||||
+7
-17
@@ -1,9 +1,3 @@
|
|||||||
export enum RunEnv {
|
|
||||||
DEV = "DEV",
|
|
||||||
DEV_SSL = "DEV_SSL",
|
|
||||||
PROD = "PROD"
|
|
||||||
}
|
|
||||||
|
|
||||||
// 基本实体模型
|
// 基本实体模型
|
||||||
export type Model = {
|
export type Model = {
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -23,17 +17,17 @@ export type ApiResponse<T> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type Page<T> = {
|
export type Page<T> = {
|
||||||
index: number;
|
|
||||||
size: number;
|
|
||||||
orderMap?: { [K in keyof T]?: OrderType };
|
orderMap?: { [K in keyof T]?: OrderType };
|
||||||
equalsExample?: Partial<T>;
|
equalsExample?: Partial<T>;
|
||||||
likesExample?: Partial<T>;
|
likesExample?: Partial<T>;
|
||||||
|
} & BasePage;
|
||||||
|
|
||||||
|
export type BasePage = {
|
||||||
|
index: number;
|
||||||
|
size: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum OrderType {
|
export type OrderType = "ASC" | "DESC";
|
||||||
ASC = "ASC",
|
|
||||||
DESC = "DESC"
|
|
||||||
}
|
|
||||||
|
|
||||||
export type PageResult<T> = {
|
export type PageResult<T> = {
|
||||||
total: number;
|
total: number;
|
||||||
@@ -53,11 +47,7 @@ export type CaptchaResult = {
|
|||||||
data: string;
|
data: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum ImageType {
|
export type ImageType = "IR-AUTO" | "IR-SMOOTH" | "IR-PIXELATED";
|
||||||
AUTO = "ir-auto",
|
|
||||||
SMOOTH = "ir-smooth",
|
|
||||||
PIXELATED = "ir-pixelated"
|
|
||||||
}
|
|
||||||
|
|
||||||
export type KeyValue<V, K = string> = {
|
export type KeyValue<V, K = string> = {
|
||||||
key: K;
|
key: K;
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import type { Model } from "./Model";
|
||||||
|
import type { User } from "./User";
|
||||||
|
|
||||||
|
/** 通知消息类型 */
|
||||||
|
export type NotifyMsgType = "INTERNAL" | "SMS" | "MAIL" | "HTTP" | "WECHAT";
|
||||||
|
|
||||||
|
/** 通知投递状态 */
|
||||||
|
export type NotifyStatus = "WAITING" | "REMINDED" | "TIMEOUT" | "CANCEL" | "FAIL";
|
||||||
|
|
||||||
|
/** 通知 */
|
||||||
|
export type Notify = {
|
||||||
|
/** 业务类型 */
|
||||||
|
bizType?: string;
|
||||||
|
/** 提醒业务类型 */
|
||||||
|
bizRemindedType?: string;
|
||||||
|
/** 提醒业务 ID */
|
||||||
|
bizRemindedId?: string;
|
||||||
|
/** 参数 */
|
||||||
|
args?: string;
|
||||||
|
/** 创建者 */
|
||||||
|
createdBy?: string;
|
||||||
|
/** 通知详情列表 */
|
||||||
|
detailList?: NotifyDetail[];
|
||||||
|
} & Model;
|
||||||
|
|
||||||
|
/** 通知详情 */
|
||||||
|
export type NotifyDetail = {
|
||||||
|
/** 所属通知 ID */
|
||||||
|
notifyId?: string;
|
||||||
|
/** 消息类型 */
|
||||||
|
msgType?: NotifyMsgType;
|
||||||
|
/** 模板 ID */
|
||||||
|
templateId?: string;
|
||||||
|
/** 标题 */
|
||||||
|
subject?: string;
|
||||||
|
/** 内容 */
|
||||||
|
data?: string;
|
||||||
|
/** 参数 */
|
||||||
|
args?: string;
|
||||||
|
/** 发送来源 */
|
||||||
|
sendFrom?: string;
|
||||||
|
/** 发送去向 */
|
||||||
|
sendTo?: string;
|
||||||
|
/** 发送时间 */
|
||||||
|
sendAt?: number;
|
||||||
|
/** 重试计数 */
|
||||||
|
retry?: number;
|
||||||
|
/** 超时时间 */
|
||||||
|
timeoutAt?: number;
|
||||||
|
/** 结果描述 */
|
||||||
|
resultDesc?: string;
|
||||||
|
/** 投递状态 */
|
||||||
|
status?: NotifyStatus;
|
||||||
|
/** 已读时间,站内通知未读时为空 */
|
||||||
|
readAt?: number;
|
||||||
|
/** 所属通知 */
|
||||||
|
notify?: Notify;
|
||||||
|
/** 发送用户 */
|
||||||
|
fromUser?: User;
|
||||||
|
/** 接收用户 */
|
||||||
|
toUser?: User;
|
||||||
|
} & Model;
|
||||||
@@ -7,11 +7,18 @@ export interface Permission {
|
|||||||
nameLangId?: string;
|
nameLangId?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
builtin?: boolean;
|
||||||
|
protectedPermission?: boolean;
|
||||||
|
ownerType?: AuthOwnerType;
|
||||||
|
ownerId?: string;
|
||||||
|
createdBy?: string;
|
||||||
createdAt?: number;
|
createdAt?: number;
|
||||||
updatedAt?: number;
|
updatedAt?: number;
|
||||||
deletedAt?: number;
|
deletedAt?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AuthOwnerType = "SYSTEM" | "MODULE" | "TENANT" | "STORE" | "USER";
|
||||||
|
|
||||||
export type PermissionPayload = Pick<Permission, "moduleCode" | "code" | "nameLangId" | "name" | "description"> & {
|
export type PermissionPayload = Pick<Permission, "moduleCode" | "code" | "nameLangId" | "name" | "description"> & {
|
||||||
id?: string;
|
id?: string;
|
||||||
};
|
};
|
||||||
|
|||||||
+20
-10
@@ -1,31 +1,41 @@
|
|||||||
import type { ModuleCode, Permission } from "./Permission";
|
import type { AuthOwnerType, ModuleCode, Permission } from "./Permission";
|
||||||
|
import { Model } from "./Model";
|
||||||
|
|
||||||
export interface Role {
|
export type Role = {
|
||||||
id: string;
|
|
||||||
moduleCode: ModuleCode;
|
moduleCode: ModuleCode;
|
||||||
code: string;
|
code: string;
|
||||||
nameLangId?: string;
|
nameLangId?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
builtin?: boolean;
|
||||||
|
protectedRole?: boolean;
|
||||||
|
parentRoleId?: string;
|
||||||
|
ownerType?: AuthOwnerType;
|
||||||
|
ownerId?: string;
|
||||||
|
createdBy?: string;
|
||||||
|
|
||||||
permissionList?: Permission[];
|
permissionList?: Permission[];
|
||||||
allPermissionList?: Permission[];
|
allPermissionList?: Permission[];
|
||||||
childRoleList?: Role[];
|
childRoleList?: Role[];
|
||||||
|
parentRole?: Role;
|
||||||
|
delegationPermissionList?: Permission[];
|
||||||
permissionIdList?: string[];
|
permissionIdList?: string[];
|
||||||
childRoleIdList?: string[];
|
childRoleIdList?: string[];
|
||||||
createdAt?: number;
|
delegationPermissionIdList?: string[];
|
||||||
updatedAt?: number;
|
} & Model;
|
||||||
deletedAt?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type RolePayload = Pick<Role, "moduleCode" | "code" | "nameLangId" | "name" | "description"> & {
|
export type RolePayload = Partial<Pick<Role, "moduleCode" | "code" | "nameLangId" | "name" | "description">> & {
|
||||||
id?: string;
|
id?: string;
|
||||||
|
parentRoleId?: string;
|
||||||
|
ownerType?: AuthOwnerType;
|
||||||
|
ownerId?: string;
|
||||||
permissionIdList?: string[];
|
permissionIdList?: string[];
|
||||||
childRoleIdList?: string[];
|
childRoleIdList?: string[];
|
||||||
|
delegationPermissionIdList?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface UserRoleAuthorizeReq {
|
export interface AuthorizeUserRole {
|
||||||
userId: string;
|
userId: string;
|
||||||
moduleCode?: ModuleCode;
|
moduleCode?: ModuleCode;
|
||||||
roleIdList?: string[];
|
roleIdList?: string[];
|
||||||
roleCodeList?: string[];
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
export enum TemplateBizType {
|
|
||||||
|
|
||||||
GIT = "GIT",
|
|
||||||
|
|
||||||
FOREVER_MC = "FOREVER_MC"
|
|
||||||
}
|
|
||||||
+12
-19
@@ -1,6 +1,9 @@
|
|||||||
import { ImageType, Model } from "./Model";
|
import { ImageType, Model } from "./Model";
|
||||||
import { Attachment } from "./Attachment";
|
import { Attachment } from "./Attachment";
|
||||||
import type { Setting } from "./Setting";
|
import type { Setting } from "./Setting";
|
||||||
|
import { Role } from "./Role";
|
||||||
|
|
||||||
|
export type Gender = "MALE" | "FEMALE";
|
||||||
|
|
||||||
export type User = {
|
export type User = {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -13,7 +16,7 @@ export type User = {
|
|||||||
wrapperType?: ImageType;
|
wrapperType?: ImageType;
|
||||||
avatarType?: ImageType;
|
avatarType?: ImageType;
|
||||||
exp?: number;
|
exp?: number;
|
||||||
sex?: number;
|
gender?: Gender;
|
||||||
birthdate?: number;
|
birthdate?: number;
|
||||||
qq?: string;
|
qq?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
@@ -21,7 +24,9 @@ export type User = {
|
|||||||
lastLoginAt?: number;
|
lastLoginAt?: number;
|
||||||
unmuteAt?: number;
|
unmuteAt?: number;
|
||||||
unbanAt?: number;
|
unbanAt?: number;
|
||||||
|
|
||||||
roleList?: string[];
|
roleList?: string[];
|
||||||
|
roleEntityList?: Role[];
|
||||||
permissionList?: string[];
|
permissionList?: string[];
|
||||||
attachmentList?: Attachment[];
|
attachmentList?: Attachment[];
|
||||||
settingList?: Setting[];
|
settingList?: Setting[];
|
||||||
@@ -32,18 +37,7 @@ export type User = {
|
|||||||
phoneNoVerified?: boolean;
|
phoneNoVerified?: boolean;
|
||||||
} & Model;
|
} & Model;
|
||||||
|
|
||||||
export enum UserAttachType {
|
export type UserAttachType = "AVATAR" | "WRAPPER";
|
||||||
|
|
||||||
AVATAR = "AVATAR",
|
|
||||||
|
|
||||||
WRAPPER = "WRAPPER",
|
|
||||||
|
|
||||||
LICENSE = "LICENSE",
|
|
||||||
|
|
||||||
DEFAULT_AVATAR = "DEFAULT_AVATAR",
|
|
||||||
|
|
||||||
DEFAULT_WRAPPER = "DEFAULT_WRAPPER"
|
|
||||||
}
|
|
||||||
|
|
||||||
export type RegisterRequest = {
|
export type RegisterRequest = {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -56,6 +50,11 @@ export type LoginRequest = {
|
|||||||
password: string;
|
password: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SmsLoginRequest = {
|
||||||
|
detailId: string;
|
||||||
|
captcha: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type UpdatePasswordRequest = {
|
export type UpdatePasswordRequest = {
|
||||||
oldValue: string;
|
oldValue: string;
|
||||||
newValue: string;
|
newValue: string;
|
||||||
@@ -76,12 +75,6 @@ export type LoginUser = {
|
|||||||
user?: User;
|
user?: User;
|
||||||
};
|
};
|
||||||
|
|
||||||
export enum LoginType {
|
|
||||||
ALERT,
|
|
||||||
IFRAME,
|
|
||||||
REDIRECT
|
|
||||||
}
|
|
||||||
|
|
||||||
export type UserLevelType = {
|
export type UserLevelType = {
|
||||||
exp: number; // 经验数值,和 UserData.exp 一样
|
exp: number; // 经验数值,和 UserData.exp 一样
|
||||||
value: number; // 经验对应等级,[0, 8]
|
value: number; // 经验对应等级,[0, 8]
|
||||||
|
|||||||
+1
-1
@@ -8,9 +8,9 @@ export * from "./Model";
|
|||||||
export * from "./Permission";
|
export * from "./Permission";
|
||||||
export * from "./Role";
|
export * from "./Role";
|
||||||
export * from "./User";
|
export * from "./User";
|
||||||
export * from "./Template";
|
|
||||||
export * from "./Comment";
|
export * from "./Comment";
|
||||||
export * from "./Developer";
|
export * from "./Developer";
|
||||||
|
export * from "./Notify";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 安装方法
|
* 安装方法
|
||||||
|
|||||||
+12
-35
@@ -1,28 +1,7 @@
|
|||||||
import Text from "./Text";
|
import Text from "./Text";
|
||||||
|
|
||||||
export enum Unit {
|
export const UNITS = ["B", "KB", "MB", "GB", "TB", "PB", "EB"] as const;
|
||||||
|
export type Unit = (typeof UNITS)[number];
|
||||||
/** B */
|
|
||||||
B = "B",
|
|
||||||
|
|
||||||
/** KB */
|
|
||||||
KB = "KB",
|
|
||||||
|
|
||||||
/** MB */
|
|
||||||
MB = "MB",
|
|
||||||
|
|
||||||
/** GB */
|
|
||||||
GB = "GB",
|
|
||||||
|
|
||||||
/** TB */
|
|
||||||
TB = "TB",
|
|
||||||
|
|
||||||
/** PB */
|
|
||||||
PB = "PB",
|
|
||||||
|
|
||||||
/** EB */
|
|
||||||
EB = "EB"
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 储存单位 */
|
/** 储存单位 */
|
||||||
export default class IOSize {
|
export default class IOSize {
|
||||||
@@ -48,8 +27,6 @@ export default class IOSize {
|
|||||||
/** 1 EB */
|
/** 1 EB */
|
||||||
public static EB = IOSize.PB << 10;
|
public static EB = IOSize.PB << 10;
|
||||||
|
|
||||||
public static Unit = Unit;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* <p>格式化一个储存容量,保留两位小数
|
* <p>格式化一个储存容量,保留两位小数
|
||||||
* <pre>
|
* <pre>
|
||||||
@@ -66,7 +43,7 @@ export default class IOSize {
|
|||||||
if (size === undefined || size === null) {
|
if (size === undefined || size === null) {
|
||||||
return "0 B";
|
return "0 B";
|
||||||
}
|
}
|
||||||
const units = Object.keys(Unit);
|
const units = Object.values(UNITS);
|
||||||
if (0 < size) {
|
if (0 < size) {
|
||||||
for (let i = 0; i < units.length; i++, size /= 1024) {
|
for (let i = 0; i < units.length; i++, size /= 1024) {
|
||||||
const unit = units[i];
|
const unit = units[i];
|
||||||
@@ -118,31 +95,31 @@ export default class IOSize {
|
|||||||
let unit: Unit;
|
let unit: Unit;
|
||||||
|
|
||||||
// 先尝试精确匹配枚举
|
// 先尝试精确匹配枚举
|
||||||
if (Object.values(Unit).includes(unitStr as Unit)) {
|
if (Object.values(UNITS).includes(unitStr as Unit)) {
|
||||||
unit = unitStr as Unit;
|
unit = unitStr as Unit;
|
||||||
} else {
|
} else {
|
||||||
// 处理单字母单位缩写(K/M/G/T/P/E)
|
// 处理单字母单位缩写(K/M/G/T/P/E)
|
||||||
switch (unitStr.charAt(0)) {
|
switch (unitStr.charAt(0)) {
|
||||||
case "K":
|
case "K":
|
||||||
unit = Unit.KB;
|
unit = "KB";
|
||||||
break;
|
break;
|
||||||
case "M":
|
case "M":
|
||||||
unit = Unit.MB;
|
unit = "MB";
|
||||||
break;
|
break;
|
||||||
case "G":
|
case "G":
|
||||||
unit = Unit.GB;
|
unit = "GB";
|
||||||
break;
|
break;
|
||||||
case "T":
|
case "T":
|
||||||
unit = Unit.TB;
|
unit = "TB";
|
||||||
break;
|
break;
|
||||||
case "P":
|
case "P":
|
||||||
unit = Unit.PB;
|
unit = "PB";
|
||||||
break;
|
break;
|
||||||
case "E":
|
case "E":
|
||||||
unit = Unit.EB;
|
unit = "EB";
|
||||||
break;
|
break;
|
||||||
case "B":
|
case "B":
|
||||||
unit = Unit.B;
|
unit = "B";
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
throw new Error("Unknown unit: " + unitStr);
|
throw new Error("Unknown unit: " + unitStr);
|
||||||
@@ -173,7 +150,7 @@ export default class IOSize {
|
|||||||
if (val === undefined || val === null) {
|
if (val === undefined || val === null) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
const units = Object.values(Unit);
|
const units = Object.values(UNITS);
|
||||||
const ordinal = units.indexOf(unit);
|
const ordinal = units.indexOf(unit);
|
||||||
return Math.round(val * Math.pow(1024, ordinal));
|
return Math.round(val * Math.pow(1024, ordinal));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import Prism from "prismjs";
|
||||||
|
|
||||||
|
const prismLanguageAliasMap: Record<string, string> = {
|
||||||
|
html: "markup",
|
||||||
|
xml: "markup",
|
||||||
|
js: "javascript",
|
||||||
|
ts: "typescript",
|
||||||
|
sh: "bash",
|
||||||
|
vue: "markup"
|
||||||
|
};
|
||||||
|
|
||||||
|
export function resolvePrismLanguage(language?: string | null) {
|
||||||
|
if (!language) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const nextLanguage = prismLanguageAliasMap[language] || language;
|
||||||
|
return Prism.languages[nextLanguage] ? nextLanguage : "";
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
export function enhancePrismCodeBlock(root: ParentNode) {
|
||||||
|
const codeList = Array.from(root.querySelectorAll("pre > code[class*='language-'], pre[class*='language-'] > code"));
|
||||||
|
|
||||||
|
for (const code of codeList) {
|
||||||
|
if (!(code instanceof HTMLElement)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pre = code.parentElement;
|
||||||
|
if (!(pre instanceof HTMLElement)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
syncPreLanguageClass(pre, code);
|
||||||
|
|
||||||
|
resetCodeBlock(code);
|
||||||
|
normalizeCodeContent(code);
|
||||||
|
|
||||||
|
const maxHeight = resolveCodeBlockMaxHeight(pre);
|
||||||
|
if (maxHeight && maxHeight !== "auto") {
|
||||||
|
pre.style.maxHeight = maxHeight;
|
||||||
|
} else {
|
||||||
|
pre.style.removeProperty("max-height");
|
||||||
|
}
|
||||||
|
|
||||||
|
const lineCount = buildLineCount(code.textContent || "");
|
||||||
|
const rows = buildLineNumberRows(lineCount);
|
||||||
|
const codes = document.createElement("span");
|
||||||
|
codes.className = "codes";
|
||||||
|
codes.innerHTML = code.innerHTML;
|
||||||
|
|
||||||
|
pre.classList.add("has-line-numbers");
|
||||||
|
code.replaceChildren(rows, codes);
|
||||||
|
bindCodeBlockToggle(pre, code, lineCount, maxHeight);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncPreLanguageClass(pre: HTMLElement, code: HTMLElement) {
|
||||||
|
const classList = Array.from(code.classList).filter((item) => item.startsWith("language-"));
|
||||||
|
if (!classList.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const className of classList) {
|
||||||
|
if (!pre.classList.contains(className)) {
|
||||||
|
pre.classList.add(className);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetCodeBlock(code: HTMLElement) {
|
||||||
|
code.parentElement?.classList.remove("has-line-numbers");
|
||||||
|
const codes = code.querySelector(":scope > .codes");
|
||||||
|
if (codes instanceof HTMLElement) {
|
||||||
|
code.innerHTML = codes.innerHTML;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCodeContent(code: HTMLElement) {
|
||||||
|
const firstNode = code.firstChild;
|
||||||
|
if (firstNode instanceof Text && firstNode.data.startsWith("\n")) {
|
||||||
|
firstNode.data = firstNode.data.slice(1);
|
||||||
|
if (!firstNode.data) {
|
||||||
|
firstNode.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastNode = code.lastChild;
|
||||||
|
if (lastNode instanceof Text && lastNode.data.endsWith("\n")) {
|
||||||
|
lastNode.data = lastNode.data.replace(/\n+$/, "");
|
||||||
|
if (!lastNode.data) {
|
||||||
|
lastNode.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveCodeBlockMaxHeight(pre: HTMLElement) {
|
||||||
|
const container = pre.closest("[data-max-height]");
|
||||||
|
if (!(container instanceof HTMLElement)) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return container.dataset.maxHeight || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLineCount(text: string) {
|
||||||
|
if (!text) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return Math.max(text.split("\n").length, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLineNumberRows(lineCount: number) {
|
||||||
|
const rows = document.createElement("span");
|
||||||
|
rows.className = "line-numbers-rows diselect";
|
||||||
|
for (let i = 0; i < lineCount; i += 1) {
|
||||||
|
const row = document.createElement("span");
|
||||||
|
row.textContent = `${i + 1}`;
|
||||||
|
rows.appendChild(row);
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindCodeBlockToggle(pre: HTMLElement, code: HTMLElement, lineCount: number, maxHeight: string) {
|
||||||
|
pre.classList.remove("expand");
|
||||||
|
pre.dataset.lineCount = `${lineCount}`;
|
||||||
|
pre.ondblclick = null;
|
||||||
|
|
||||||
|
if (!maxHeight || maxHeight === "auto" || lineCount < 18) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pre.ondblclick = () => {
|
||||||
|
const isExpand = pre.classList.contains("expand");
|
||||||
|
if (isExpand) {
|
||||||
|
pre.style.maxHeight = maxHeight;
|
||||||
|
pre.classList.remove("expand");
|
||||||
|
} else {
|
||||||
|
const lineHeight = Number.parseFloat(getComputedStyle(code).lineHeight || "22") || 22;
|
||||||
|
pre.style.maxHeight = `${Math.ceil(lineCount * lineHeight + 2)}px`;
|
||||||
|
pre.classList.add("expand");
|
||||||
|
}
|
||||||
|
window.getSelection()?.removeAllRanges();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
+29
-25
@@ -1,23 +1,25 @@
|
|||||||
export enum PrismjsType {
|
export const PrismjsType = {
|
||||||
PlainText = "PlainText",
|
PlainText: "PlainText",
|
||||||
Markdown = "Markdown",
|
Markdown: "Markdown",
|
||||||
JavaScript = "JavaScript",
|
JavaScript: "JavaScript",
|
||||||
TypeScript = "TypeScript",
|
TypeScript: "TypeScript",
|
||||||
Initialization = "Initialization",
|
Initialization: "Initialization",
|
||||||
PHP = "PHP",
|
PHP: "PHP",
|
||||||
SQL = "SQL",
|
SQL: "SQL",
|
||||||
XML = "XML",
|
XML: "XML",
|
||||||
CSS = "CSS",
|
CSS: "CSS",
|
||||||
VUE = "VUE",
|
VUE: "VUE",
|
||||||
LESS = "LESS",
|
LESS: "LESS",
|
||||||
Markup = "Markup",
|
Markup: "Markup",
|
||||||
YAML = "YAML",
|
YAML: "YAML",
|
||||||
Json = "Json",
|
Json: "Json",
|
||||||
Java = "Java",
|
Java: "Java",
|
||||||
Properties = "Properties",
|
Properties: "Properties",
|
||||||
NginxConf = "NginxConf",
|
NginxConf: "NginxConf",
|
||||||
ApacheConf = "ApacheConf"
|
ApacheConf: "ApacheConf"
|
||||||
}
|
} as const;
|
||||||
|
|
||||||
|
export type PrismjsType = (typeof PrismjsType)[keyof typeof PrismjsType];
|
||||||
|
|
||||||
export type PrismjsProperties = {
|
export type PrismjsProperties = {
|
||||||
|
|
||||||
@@ -26,14 +28,16 @@ export type PrismjsProperties = {
|
|||||||
viewer: PrismjsViewer;
|
viewer: PrismjsViewer;
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum PrismjsViewer {
|
export const PrismjsViewer = {
|
||||||
|
|
||||||
MARKDOWN = "MARKDOWN",
|
MARKDOWN: "MARKDOWN",
|
||||||
|
|
||||||
CODE = "CODE",
|
CODE: "CODE",
|
||||||
|
|
||||||
TEXT = "TEXT",
|
TEXT: "TEXT"
|
||||||
}
|
} as const;
|
||||||
|
|
||||||
|
export type PrismjsViewer = (typeof PrismjsViewer)[keyof typeof PrismjsViewer];
|
||||||
|
|
||||||
export default class Prismjs {
|
export default class Prismjs {
|
||||||
|
|
||||||
|
|||||||
+15
-20
@@ -1,7 +1,7 @@
|
|||||||
export default class Storage {
|
export default class Storage {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取为布尔值
|
* 获取为布尔值,键不存在时返回 false
|
||||||
*
|
*
|
||||||
* @param key 键
|
* @param key 键
|
||||||
* @returns 布尔值
|
* @returns 布尔值
|
||||||
@@ -11,7 +11,7 @@ export default class Storage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取为布尔值并取反
|
* 获取为布尔值并取反,键不存在时返回 true
|
||||||
*
|
*
|
||||||
* @param key 键
|
* @param key 键
|
||||||
* @returns 布尔值
|
* @returns 布尔值
|
||||||
@@ -25,13 +25,11 @@ export default class Storage {
|
|||||||
*
|
*
|
||||||
* @template T 对象类型
|
* @template T 对象类型
|
||||||
* @param key 键
|
* @param key 键
|
||||||
* @returns {T | undefined} 返回对象
|
* @returns 对象,键不存在时返回 null
|
||||||
*/
|
*/
|
||||||
public static getObject<T>(key: string): T {
|
public static getObject<T>(key: string): T | null {
|
||||||
if (this.has(key)) {
|
const value = this.getJSON(key);
|
||||||
return this.getJSON(key) as T;
|
return value === null ? null : value as T;
|
||||||
}
|
|
||||||
throw Error(`not found ${key}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -54,24 +52,21 @@ export default class Storage {
|
|||||||
* 获取为 JSON
|
* 获取为 JSON
|
||||||
*
|
*
|
||||||
* @param key 键
|
* @param key 键
|
||||||
* @returns JSON 对象
|
* @returns JSON 对象,键不存在时返回 null;值无法解析时抛错
|
||||||
*/
|
*/
|
||||||
public static getJSON(key: string) {
|
public static getJSON(key: string): unknown {
|
||||||
return JSON.parse(this.getString(key));
|
const value = this.getString(key);
|
||||||
|
return value === null ? null : JSON.parse(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取为字符串(其他获取方式一般经过这个方法,找不到配置或配置值无效时会抛错)
|
* 获取为字符串
|
||||||
*
|
*
|
||||||
* @param key 键
|
* @param key 键
|
||||||
* @returns 字符串
|
* @returns 字符串,键不存在时返回 null
|
||||||
*/
|
*/
|
||||||
public static getString(key: string): string {
|
public static getString(key: string): string | null {
|
||||||
const value = localStorage.getItem(key);
|
return localStorage.getItem(key);
|
||||||
if (value) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
throw new Error(`not found: ${key}, ${value}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -81,7 +76,7 @@ export default class Storage {
|
|||||||
* @returns true 为存在
|
* @returns true 为存在
|
||||||
*/
|
*/
|
||||||
public static has(key: string): boolean {
|
public static has(key: string): boolean {
|
||||||
return localStorage.getItem(key) !== undefined && localStorage.getItem(key) !== null;
|
return localStorage.getItem(key) !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import type { AppContext } from "vue";
|
||||||
|
|
||||||
|
type MessageType = "info" | "success" | "warning" | "error";
|
||||||
|
|
||||||
|
type MessageHandler = (content: string) => unknown | Promise<unknown>;
|
||||||
|
|
||||||
|
type MessageApi = {
|
||||||
|
[type in MessageType]?: MessageHandler;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const showTDesignMessage = async (
|
||||||
|
appContext: AppContext | undefined,
|
||||||
|
type: MessageType,
|
||||||
|
content: string
|
||||||
|
) => {
|
||||||
|
const handler = (appContext?.config.globalProperties.$message as MessageApi | undefined)?.[type];
|
||||||
|
if (handler) {
|
||||||
|
await handler(content);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ("error" === type) {
|
||||||
|
console.error(content);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.warn(content);
|
||||||
|
};
|
||||||
|
|
||||||
+46
-16
@@ -232,43 +232,73 @@ export default class Toolkit {
|
|||||||
return Object.keys(e)[Object.values(e).indexOf(value)];
|
return Object.keys(e)[Object.values(e).indexOf(value)];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 防抖
|
/**
|
||||||
// eslint-disable-next-line
|
* 防抖函数
|
||||||
public static debounce<T extends (...args: any[]) => any>(callback: T, defaultImmediate = true, delay = 600): T & {
|
*
|
||||||
cancel(): void
|
* 支持两种模式:
|
||||||
} {
|
* - **立即模式**(`immediate = true`,默认):首次调用立即执行,连续调用在延迟结束后再执行最后一次(leading + trailing)
|
||||||
let timerId: ReturnType<typeof setTimeout> | null = null; // 存储定时器
|
* - **延迟模式**(`immediate = false`):所有调用在延迟结束后执行最后一次(trailing only)
|
||||||
let immediate = defaultImmediate;
|
*
|
||||||
// 定义一个 cancel 办法,用于勾销防抖
|
* @param callback 回调函数
|
||||||
|
* @param immediate 是否首次调用立即执行,默认 true
|
||||||
|
* @param delay 延迟毫秒数,默认 600
|
||||||
|
* @returns 防抖后的函数,附带 `cancel()` 方法用于取消 pending 执行并重置状态
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* // 立即模式(默认):首次立即触发,连续调用后在延迟结束时再触发一次
|
||||||
|
* const fn = Toolkit.debounce((val: string) => {
|
||||||
|
* console.log(val);
|
||||||
|
* });
|
||||||
|
* fn("a"); // 立即输出 "a"
|
||||||
|
* fn("b"); // 600ms 后输出 "b"(trailing)
|
||||||
|
* fn.cancel(); // 取消 pending 的 trailing 调用,重置为初始状态
|
||||||
|
*
|
||||||
|
* // 延迟模式:连续调用只在停止后触发最后一次
|
||||||
|
* const fn2 = Toolkit.debounce((val: string) => {
|
||||||
|
* console.log(val);
|
||||||
|
* }, false);
|
||||||
|
* fn2("a"); // 不输出
|
||||||
|
* fn2("b"); // 600ms 后输出 "b"
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
public static debounce<T extends (...args: any[]) => any>(
|
||||||
|
callback: T,
|
||||||
|
immediate = true,
|
||||||
|
delay = 300
|
||||||
|
): ((...args: Parameters<T>) => void) & { cancel(): void } {
|
||||||
|
let timerId: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let leading = immediate;
|
||||||
|
|
||||||
const cancel = (): void => {
|
const cancel = (): void => {
|
||||||
if (timerId) {
|
if (timerId) {
|
||||||
clearTimeout(timerId);
|
clearTimeout(timerId);
|
||||||
timerId = null;
|
timerId = null;
|
||||||
}
|
}
|
||||||
|
leading = immediate;
|
||||||
};
|
};
|
||||||
|
|
||||||
const debounced = function (this: ThisParameterType<T>, ...args: Parameters<T>): void {
|
const debounced = function (this: ThisParameterType<T>, ...args: Parameters<T>): void {
|
||||||
const context = this;
|
const context = this;
|
||||||
if (timerId) {
|
if (timerId) {
|
||||||
cancel();
|
clearTimeout(timerId);
|
||||||
|
timerId = null;
|
||||||
}
|
}
|
||||||
if (immediate) {
|
if (leading) {
|
||||||
callback.apply(context, args);
|
callback.apply(context, args);
|
||||||
immediate = false;
|
leading = false;
|
||||||
timerId = setTimeout(() => {
|
timerId = setTimeout(() => {
|
||||||
immediate = defaultImmediate;
|
leading = immediate;
|
||||||
}, delay);
|
}, delay);
|
||||||
} else {
|
} else {
|
||||||
// 设置定时器,在延迟时间后执行指标函数
|
|
||||||
timerId = setTimeout(() => {
|
timerId = setTimeout(() => {
|
||||||
callback.apply(context, args);
|
callback.apply(context, args);
|
||||||
immediate = defaultImmediate;
|
leading = immediate;
|
||||||
}, delay);
|
}, delay);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// 将 cancel 方法附加到 debounced 函数上
|
|
||||||
(debounced as any).cancel = cancel;
|
(debounced as any).cancel = cancel;
|
||||||
return debounced as T & { cancel(): void };
|
return debounced as ((...args: Parameters<T>) => void) & { cancel(): void };
|
||||||
}
|
}
|
||||||
|
|
||||||
public static toUserLevel(exp?: number): UserLevelType {
|
public static toUserLevel(exp?: number): UserLevelType {
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
import type { Directive, DirectiveBinding } from "vue";
|
import type { Directive, DirectiveBinding } from "vue";
|
||||||
import Toolkit from "../Toolkit";
|
import Toolkit from "../Toolkit";
|
||||||
|
|
||||||
export enum PopupType {
|
export type PopupType = "TEXT" | "IMG" | "HTML" | "ELEMENT";
|
||||||
TEXT,
|
|
||||||
IMG,
|
|
||||||
HTML,
|
|
||||||
EL
|
|
||||||
}
|
|
||||||
|
|
||||||
/** */
|
/** */
|
||||||
export type PopupConfig = {
|
export type PopupConfig = {
|
||||||
@@ -30,7 +25,7 @@ const VPopup: Directive = {
|
|||||||
config = binding.value as PopupConfig;
|
config = binding.value as PopupConfig;
|
||||||
} else {
|
} else {
|
||||||
config = {
|
config = {
|
||||||
type: PopupType.TEXT,
|
type: "TEXT",
|
||||||
value: binding.value as any as string,
|
value: binding.value as any as string,
|
||||||
canShow: () => true
|
canShow: () => true
|
||||||
};
|
};
|
||||||
@@ -58,24 +53,24 @@ const VPopup: Directive = {
|
|||||||
popup.appendChild(el);
|
popup.appendChild(el);
|
||||||
}
|
}
|
||||||
switch (config.type) {
|
switch (config.type) {
|
||||||
case PopupType.TEXT:
|
case "TEXT":
|
||||||
// 文本
|
// 文本
|
||||||
el = document.createElement("div");
|
el = document.createElement("div");
|
||||||
el.className = "text";
|
el.className = "text";
|
||||||
el.textContent = config.value as string;
|
el.textContent = config.value as string;
|
||||||
popup.appendChild(el);
|
popup.appendChild(el);
|
||||||
break;
|
break;
|
||||||
case PopupType.IMG:
|
case "IMG":
|
||||||
// 图片
|
// 图片
|
||||||
el = document.createElement("img");
|
el = document.createElement("img");
|
||||||
(el as HTMLImageElement).src = config.value as string;
|
(el as HTMLImageElement).src = config.value as string;
|
||||||
popup.appendChild(el);
|
popup.appendChild(el);
|
||||||
break;
|
break;
|
||||||
case PopupType.HTML:
|
case "HTML":
|
||||||
// HTML 字符串
|
// HTML 字符串
|
||||||
popup.appendChild(Toolkit.toDOM(config.value as string));
|
popup.appendChild(Toolkit.toDOM(config.value as string));
|
||||||
break;
|
break;
|
||||||
case PopupType.EL:
|
case "ELEMENT":
|
||||||
// DOM 节点
|
// DOM 节点
|
||||||
if (config.value instanceof HTMLElement) {
|
if (config.value instanceof HTMLElement) {
|
||||||
const valueEl = config.value as HTMLElement;
|
const valueEl = config.value as HTMLElement;
|
||||||
|
|||||||
+14
-4
@@ -35,15 +35,24 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
external: [
|
external: [
|
||||||
"vue"
|
"vue",
|
||||||
|
"tdesign-vue-next"
|
||||||
],
|
],
|
||||||
output: {
|
output: {
|
||||||
globals: {
|
globals: {
|
||||||
vue: "Vue"
|
vue: "Vue",
|
||||||
|
"tdesign-vue-next": "TDesign"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
minify: "terser",
|
commonjsOptions: {
|
||||||
|
esmExternals: [
|
||||||
|
"vue",
|
||||||
|
"tdesign-vue-next"
|
||||||
|
],
|
||||||
|
requireReturnsDefault: false
|
||||||
|
},
|
||||||
|
minify: "terser"
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
vue({
|
vue({
|
||||||
@@ -67,7 +76,8 @@ export default defineConfig({
|
|||||||
"javascript",
|
"javascript",
|
||||||
"typescript",
|
"typescript",
|
||||||
"apacheconf",
|
"apacheconf",
|
||||||
"properties"
|
"properties",
|
||||||
|
"bash"
|
||||||
],
|
],
|
||||||
plugins: [
|
plugins: [
|
||||||
"line-numbers"
|
"line-numbers"
|
||||||
|
|||||||
Reference in New Issue
Block a user